diff --git a/.gitignore b/.gitignore index 6e855825..ecfb7207 100644 --- a/.gitignore +++ b/.gitignore @@ -44,5 +44,6 @@ test.py .venv/ uv.lock /test +plugins.bak coverage.xml -.coverage \ No newline at end of file +.coverage diff --git a/pkg/api/http/controller/groups/resources/__init__.py b/pkg/api/http/controller/groups/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pkg/api/http/controller/groups/resources/mcp.py b/pkg/api/http/controller/groups/resources/mcp.py new file mode 100644 index 00000000..ac91abff --- /dev/null +++ b/pkg/api/http/controller/groups/resources/mcp.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import quart +import traceback + + +from ... import group + + +@group.group_class('mcp', '/api/v1/mcp') +class MCPRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('/servers', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN) + async def _() -> str: + """获取MCP服务器列表""" + if quart.request.method == 'GET': + servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True) + + return self.success(data={'servers': servers}) + + elif quart.request.method == 'POST': + data = await quart.request.json + + try: + uuid = await self.ap.mcp_service.create_mcp_server(data) + return self.success(data={'uuid': uuid}) + except Exception as e: + traceback.print_exc() + return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}') + + @self.route('/servers/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN) + async def _(server_name: str) -> str: + """获取、更新或删除MCP服务器配置""" + + server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name) + if server_data is None: + return self.http_status(404, -1, 'Server not found') + + if quart.request.method == 'GET': + return self.success(data={'server': server_data}) + + elif quart.request.method == 'PUT': + data = await quart.request.json + try: + await self.ap.mcp_service.update_mcp_server(server_data['uuid'], data) + return self.success() + except Exception as e: + return self.http_status(500, -1, f'Failed to update MCP server: {str(e)}') + + elif quart.request.method == 'DELETE': + try: + await self.ap.mcp_service.delete_mcp_server(server_data['uuid']) + return self.success() + except Exception as e: + return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}') + + @self.route('/servers//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _(server_name: str) -> str: + """测试MCP服务器连接""" + server_data = await quart.request.json + task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data) + return self.success(data={'task_id': task_id}) diff --git a/pkg/api/http/controller/main.py b/pkg/api/http/controller/main.py index e45b461d..4f6d30af 100644 --- a/pkg/api/http/controller/main.py +++ b/pkg/api/http/controller/main.py @@ -15,12 +15,14 @@ from .groups import provider as groups_provider from .groups import platform as groups_platform from .groups import pipelines as groups_pipelines from .groups import knowledge as groups_knowledge +from .groups import resources as groups_resources importutil.import_modules_in_pkg(groups) importutil.import_modules_in_pkg(groups_provider) importutil.import_modules_in_pkg(groups_platform) importutil.import_modules_in_pkg(groups_pipelines) importutil.import_modules_in_pkg(groups_knowledge) +importutil.import_modules_in_pkg(groups_resources) class HTTPController: diff --git a/pkg/api/http/service/mcp.py b/pkg/api/http/service/mcp.py new file mode 100644 index 00000000..3766e7d6 --- /dev/null +++ b/pkg/api/http/service/mcp.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import sqlalchemy +import uuid +import asyncio + +from ....core import app +from ....entity.persistence import mcp as persistence_mcp +from ....core import taskmgr +from ....provider.tools.loaders.mcp import RuntimeMCPSession, MCPSessionStatus + + +class MCPService: + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + async def get_runtime_info(self, server_name: str) -> dict | None: + session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name) + if session: + return session.get_runtime_info_dict() + return None + + async def get_mcp_servers(self, contain_runtime_info: bool = False) -> list[dict]: + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer)) + + servers = result.all() + serialized_servers = [ + self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) for server in servers + ] + if contain_runtime_info: + for server in serialized_servers: + runtime_info = await self.get_runtime_info(server['name']) + + server['runtime_info'] = runtime_info if runtime_info else None + + return serialized_servers + + async def create_mcp_server(self, server_data: dict) -> str: + server_data['uuid'] = str(uuid.uuid4()) + await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data)) + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_data['uuid']) + ) + server_entity = result.first() + if server_entity: + server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity) + if self.ap.tool_mgr.mcp_tool_loader: + task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)) + self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task) + + return server_data['uuid'] + + async def get_mcp_server_by_name(self, server_name: str) -> dict | None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name) + ) + server = result.first() + if server is None: + return None + + runtime_info = await self.get_runtime_info(server.name) + server_data = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) + server_data['runtime_info'] = runtime_info if runtime_info else None + return server_data + + async def update_mcp_server(self, server_uuid: str, server_data: dict) -> None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + old_server = result.first() + old_server_name = old_server.name if old_server else None + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(persistence_mcp.MCPServer) + .where(persistence_mcp.MCPServer.uuid == server_uuid) + .values(server_data) + ) + + if self.ap.tool_mgr.mcp_tool_loader: + if old_server_name and old_server_name in self.ap.tool_mgr.mcp_tool_loader.sessions: + await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name) + + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + updated_server = result.first() + if updated_server: + # convert entity to config dict + server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server) + task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config)) + self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task) + + async def delete_mcp_server(self, server_uuid: str) -> None: + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + server = result.first() + server_name = server.name if server else None + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid) + ) + + if server_name and self.ap.tool_mgr.mcp_tool_loader: + if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions: + await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name) + + async def test_mcp_server(self, server_name: str, server_data: dict) -> int: + """测试 MCP 服务器连接并返回任务 ID""" + + runtime_mcp_session: RuntimeMCPSession | None = None + + if server_name != '_': + runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name) + if runtime_mcp_session is None: + raise ValueError(f'Server not found: {server_name}') + + if runtime_mcp_session.status == MCPSessionStatus.ERROR: + coroutine = runtime_mcp_session.start() + else: + coroutine = runtime_mcp_session.refresh() + else: + runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data) + coroutine = runtime_mcp_session.start() + + ctx = taskmgr.TaskContext.new() + wrapper = self.ap.task_mgr.create_user_task( + coroutine, + kind='mcp-operation', + name=f'mcp-test-{server_name}', + label=f'Testing MCP server {server_name}', + context=ctx, + ) + return wrapper.id diff --git a/pkg/core/app.py b/pkg/core/app.py index 27b780f6..62e47b74 100644 --- a/pkg/core/app.py +++ b/pkg/core/app.py @@ -22,6 +22,7 @@ from ..api.http.service import model as model_service from ..api.http.service import pipeline as pipeline_service from ..api.http.service import bot as bot_service from ..api.http.service import knowledge as knowledge_service +from ..api.http.service import mcp as mcp_service from ..discover import engine as discover_engine from ..storage import mgr as storagemgr from ..utils import logcache @@ -119,6 +120,8 @@ class Application: knowledge_service: knowledge_service.KnowledgeService = None + mcp_service: mcp_service.MCPService = None + def __init__(self): pass diff --git a/pkg/core/stages/build_app.py b/pkg/core/stages/build_app.py index 54a64ae8..8df32755 100644 --- a/pkg/core/stages/build_app.py +++ b/pkg/core/stages/build_app.py @@ -19,6 +19,7 @@ from ...api.http.service import model as model_service from ...api.http.service import pipeline as pipeline_service from ...api.http.service import bot as bot_service from ...api.http.service import knowledge as knowledge_service +from ...api.http.service import mcp as mcp_service from ...discover import engine as discover_engine from ...storage import mgr as storagemgr from ...utils import logcache @@ -126,5 +127,8 @@ class BuildAppStage(stage.BootingStage): knowledge_service_inst = knowledge_service.KnowledgeService(ap) ap.knowledge_service = knowledge_service_inst + mcp_service_inst = mcp_service.MCPService(ap) + ap.mcp_service = mcp_service_inst + ctrl = controller.Controller(ap) ap.ctrl = ctrl diff --git a/pkg/core/taskmgr.py b/pkg/core/taskmgr.py index ca6eb029..4eee7104 100644 --- a/pkg/core/taskmgr.py +++ b/pkg/core/taskmgr.py @@ -156,7 +156,7 @@ class TaskWrapper: 'state': self.task._state, 'exception': self.assume_exception().__str__() if self.assume_exception() is not None else None, 'exception_traceback': exception_traceback, - 'result': self.assume_result().__str__() if self.assume_result() is not None else None, + 'result': self.assume_result() if self.assume_result() is not None else None, }, } diff --git a/pkg/entity/persistence/mcp.py b/pkg/entity/persistence/mcp.py new file mode 100644 index 00000000..74478dc7 --- /dev/null +++ b/pkg/entity/persistence/mcp.py @@ -0,0 +1,20 @@ +import sqlalchemy + +from .base import Base + + +class MCPServer(Base): + __tablename__ = 'mcp_servers' + + uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) + name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) + mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse + extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + updated_at = sqlalchemy.Column( + sqlalchemy.DateTime, + nullable=False, + server_default=sqlalchemy.func.now(), + onupdate=sqlalchemy.func.now(), + ) diff --git a/pkg/platform/sources/discord.py b/pkg/platform/sources/discord.py index 933961de..98791260 100644 --- a/pkg/platform/sources/discord.py +++ b/pkg/platform/sources/discord.py @@ -22,6 +22,7 @@ import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_ from ..logger import EventLogger + # 语音功能相关异常定义 class VoiceConnectionError(Exception): """语音连接基础异常""" diff --git a/pkg/plugin/connector.py b/pkg/plugin/connector.py index 5fa745ec..4b5809fe 100644 --- a/pkg/plugin/connector.py +++ b/pkg/plugin/connector.py @@ -58,7 +58,7 @@ class PluginRuntimeConnector: async def heartbeat_loop(self): while True: - await asyncio.sleep(10) + await asyncio.sleep(20) try: await self.ping_plugin_runtime() self.ap.logger.debug('Heartbeat to plugin runtime success.') diff --git a/pkg/provider/modelmgr/modelmgr.py b/pkg/provider/modelmgr/modelmgr.py index d649b41e..f0bec0a5 100644 --- a/pkg/provider/modelmgr/modelmgr.py +++ b/pkg/provider/modelmgr/modelmgr.py @@ -59,7 +59,7 @@ class ModelManager: try: await self.load_llm_model(llm_model) except provider_errors.RequesterNotFoundError as e: - self.ap.logger.warning(f'Requester {e.requester_name} not found, skipping model {llm_model.uuid}') + self.ap.logger.warning(f'Requester {e.requester_name} not found, skipping llm model {llm_model.uuid}') except Exception as e: self.ap.logger.error(f'Failed to load model {llm_model.uuid}: {e}\n{traceback.format_exc()}') @@ -67,7 +67,14 @@ class ModelManager: result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel)) embedding_models = result.all() for embedding_model in embedding_models: - await self.load_embedding_model(embedding_model) + try: + await self.load_embedding_model(embedding_model) + except provider_errors.RequesterNotFoundError as e: + self.ap.logger.warning( + f'Requester {e.requester_name} not found, skipping embedding model {embedding_model.uuid}' + ) + except Exception as e: + self.ap.logger.error(f'Failed to load model {embedding_model.uuid}: {e}\n{traceback.format_exc()}') async def init_runtime_llm_model( self, @@ -107,6 +114,9 @@ class ModelManager: elif isinstance(model_info, dict): model_info = persistence_model.EmbeddingModel(**model_info) + if model_info.requester not in self.requester_dict: + raise provider_errors.RequesterNotFoundError(model_info.requester) + requester_inst = self.requester_dict[model_info.requester](ap=self.ap, config=model_info.requester_config) await requester_inst.initialize() diff --git a/pkg/provider/tools/loaders/mcp.py b/pkg/provider/tools/loaders/mcp.py index 36fa9751..edff9e01 100644 --- a/pkg/provider/tools/loaders/mcp.py +++ b/pkg/provider/tools/loaders/mcp.py @@ -1,7 +1,11 @@ from __future__ import annotations +import enum import typing from contextlib import AsyncExitStack +import traceback +import sqlalchemy +import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client @@ -10,6 +14,13 @@ from mcp.client.sse import sse_client from .. import loader from ....core import app import langbot_plugin.api.entities.builtin.resource.tool as resource_tool +from ....entity.persistence import mcp as persistence_mcp + + +class MCPSessionStatus(enum.Enum): + CONNECTING = 'connecting' + CONNECTED = 'connected' + ERROR = 'error' class RuntimeMCPSession: @@ -27,16 +38,26 @@ class RuntimeMCPSession: functions: list[resource_tool.LLMTool] = [] - def __init__(self, server_name: str, server_config: dict, ap: app.Application): + enable: bool + + # connected: bool + status: MCPSessionStatus + + last_test_error_message: str + + def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application): self.server_name = server_name self.server_config = server_config self.ap = ap - + self.enable = enable self.session = None self.exit_stack = AsyncExitStack() self.functions = [] + self.status = MCPSessionStatus.CONNECTING + self.last_test_error_message = '' + async def _init_stdio_python_server(self): server_params = StdioServerParameters( command=self.server_config['command'], @@ -58,6 +79,7 @@ class RuntimeMCPSession: self.server_config['url'], headers=self.server_config.get('headers', {}), timeout=self.server_config.get('timeout', 10), + sse_read_timeout=self.server_config.get('ssereadtimeout', 30), ) ) @@ -67,19 +89,33 @@ class RuntimeMCPSession: await self.session.initialize() - async def initialize(self): - self.ap.logger.debug(f'初始化 MCP 会话: {self.server_name} {self.server_config}') + async def start(self): + if not self.enable: + return - if self.server_config['mode'] == 'stdio': - await self._init_stdio_python_server() - elif self.server_config['mode'] == 'sse': - await self._init_sse_server() - else: - raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}') + try: + if self.server_config['mode'] == 'stdio': + await self._init_stdio_python_server() + elif self.server_config['mode'] == 'sse': + await self._init_sse_server() + else: + raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}') + + await self.refresh() + + self.status = MCPSessionStatus.CONNECTED + self.last_test_error_message = '' + except Exception as e: + self.status = MCPSessionStatus.ERROR + self.last_test_error_message = str(e) + raise e + + async def refresh(self): + self.functions.clear() tools = await self.session.list_tools() - self.ap.logger.debug(f'获取 MCP 工具: {tools}') + self.ap.logger.debug(f'Refresh MCP tools: {tools}') for tool in tools.tools: @@ -101,58 +137,201 @@ class RuntimeMCPSession: ) ) + def get_tools(self) -> list[resource_tool.LLMTool]: + return self.functions + + def get_runtime_info_dict(self) -> dict: + return { + 'status': self.status.value, + 'error_message': self.last_test_error_message, + 'tool_count': len(self.get_tools()), + 'tools': [ + { + 'name': tool.name, + 'description': tool.description, + } + for tool in self.get_tools() + ], + } + async def shutdown(self): - """关闭工具""" - await self.session._exit_stack.aclose() + """关闭会话并清理资源""" + try: + if self.exit_stack: + await self.exit_stack.aclose() + self.functions.clear() + self.session = None + except Exception as e: + self.ap.logger.error(f'Error shutting down MCP session {self.server_name}: {e}\n{traceback.format_exc()}') -@loader.loader_class('mcp') +# @loader.loader_class('mcp') class MCPLoader(loader.ToolLoader): """MCP 工具加载器。 在此加载器中管理所有与 MCP Server 的连接。 """ - sessions: dict[str, RuntimeMCPSession] = {} + sessions: dict[str, RuntimeMCPSession] - _last_listed_functions: list[resource_tool.LLMTool] = [] + _last_listed_functions: list[resource_tool.LLMTool] + + _hosted_mcp_tasks: list[asyncio.Task] def __init__(self, ap: app.Application): super().__init__(ap) self.sessions = {} self._last_listed_functions = [] + self._hosted_mcp_tasks = [] async def initialize(self): - for server_config in self.ap.instance_config.data.get('mcp', {}).get('servers', []): - if not server_config['enable']: - continue - session = RuntimeMCPSession(server_config['name'], server_config, self.ap) - await session.initialize() - # self.ap.event_loop.create_task(session.initialize()) + await self.load_mcp_servers_from_db() + + async def load_mcp_servers_from_db(self): + self.ap.logger.info('Loading MCP servers from db...') + + self.sessions = {} + + result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer)) + servers = result.all() + + for server in servers: + config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) + + task = asyncio.create_task(self.host_mcp_server(config)) + self._hosted_mcp_tasks.append(task) + + async def host_mcp_server(self, server_config: dict): + self.ap.logger.debug(f'Loading MCP server {server_config}') + try: + session = await self.load_mcp_server(server_config) self.sessions[server_config['name']] = session + except Exception as e: + self.ap.logger.error( + f'Failed to load MCP server from db: {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}' + ) + return + + self.ap.logger.debug(f'Starting MCP server {server_config["name"]}({server_config["uuid"]})') + try: + await session.start() + except Exception as e: + self.ap.logger.error( + f'Failed to start MCP server {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}' + ) + return + + self.ap.logger.debug(f'Started MCP server {server_config["name"]}({server_config["uuid"]})') + + async def load_mcp_server(self, server_config: dict) -> RuntimeMCPSession: + """加载 MCP 服务器到运行时 + + Args: + server_config: 服务器配置字典,必须包含: + - name: 服务器名称 + - mode: 连接模式 (stdio/sse) + - enable: 是否启用 + - extra_args: 额外的配置参数 (可选) + """ + + name = server_config['name'] + mode = server_config['mode'] + enable = server_config['enable'] + extra_args = server_config.get('extra_args', {}) + + mixed_config = { + 'name': name, + 'mode': mode, + 'enable': enable, + **extra_args, + } + + session = RuntimeMCPSession(name, mixed_config, enable, self.ap) + + return session async def get_tools(self) -> list[resource_tool.LLMTool]: all_functions = [] for session in self.sessions.values(): - all_functions.extend(session.functions) + all_functions.extend(session.get_tools()) self._last_listed_functions = all_functions return all_functions async def has_tool(self, name: str) -> bool: - return name in [f.name for f in self._last_listed_functions] + """检查工具是否存在""" + for session in self.sessions.values(): + for function in session.get_tools(): + if function.name == name: + return True + return False async def invoke_tool(self, name: str, parameters: dict) -> typing.Any: - for server_name, session in self.sessions.items(): - for function in session.functions: + """执行工具调用""" + for session in self.sessions.values(): + for function in session.get_tools(): if function.name == name: - return await function.func(**parameters) + self.ap.logger.debug(f'Invoking MCP tool: {name} with parameters: {parameters}') + try: + result = await function.func(**parameters) + self.ap.logger.debug(f'MCP tool {name} executed successfully') + return result + except Exception as e: + self.ap.logger.error(f'Error invoking MCP tool {name}: {e}\n{traceback.format_exc()}') + raise - raise ValueError(f'未找到工具: {name}') + raise ValueError(f'Tool not found: {name}') + + async def remove_mcp_server(self, server_name: str): + """移除 MCP 服务器""" + if server_name not in self.sessions: + self.ap.logger.warning(f'MCP server {server_name} not found in sessions, skipping removal') + return + + session = self.sessions.pop(server_name) + await session.shutdown() + self.ap.logger.info(f'Removed MCP server: {server_name}') + + def get_session(self, server_name: str) -> RuntimeMCPSession | None: + """获取指定名称的 MCP 会话""" + return self.sessions.get(server_name) + + def has_session(self, server_name: str) -> bool: + """检查是否存在指定名称的 MCP 会话""" + return server_name in self.sessions + + def get_all_server_names(self) -> list[str]: + """获取所有已加载的 MCP 服务器名称""" + return list(self.sessions.keys()) + + def get_server_tool_count(self, server_name: str) -> int: + """获取指定服务器的工具数量""" + session = self.get_session(server_name) + return len(session.get_tools()) if session else 0 + + def get_all_servers_info(self) -> dict[str, dict]: + """获取所有服务器的信息""" + info = {} + for server_name, session in self.sessions.items(): + info[server_name] = { + 'name': server_name, + 'mode': session.server_config.get('mode'), + 'enable': session.enable, + 'tools_count': len(session.get_tools()), + 'tool_names': [f.name for f in session.get_tools()], + } + return info async def shutdown(self): - """关闭工具""" - for session in self.sessions.values(): - await session.shutdown() + """关闭所有工具""" + self.ap.logger.info('Shutting down all MCP sessions...') + for server_name, session in list(self.sessions.items()): + try: + await session.shutdown() + self.ap.logger.debug(f'Shutdown MCP session: {server_name}') + except Exception as e: + self.ap.logger.error(f'Error shutting down MCP session {server_name}: {e}\n{traceback.format_exc()}') + self.sessions.clear() + self.ap.logger.info('All MCP sessions shutdown complete') diff --git a/pkg/provider/tools/loaders/plugin.py b/pkg/provider/tools/loaders/plugin.py index 94296470..5c702d10 100644 --- a/pkg/provider/tools/loaders/plugin.py +++ b/pkg/provider/tools/loaders/plugin.py @@ -7,7 +7,7 @@ from .. import loader import langbot_plugin.api.entities.builtin.resource.tool as resource_tool -@loader.loader_class('plugin-tool-loader') +# @loader.loader_class('plugin-tool-loader') class PluginToolLoader(loader.ToolLoader): """插件工具加载器。 diff --git a/pkg/provider/tools/toolmgr.py b/pkg/provider/tools/toolmgr.py index 43960aba..2e918331 100644 --- a/pkg/provider/tools/toolmgr.py +++ b/pkg/provider/tools/toolmgr.py @@ -3,9 +3,9 @@ from __future__ import annotations import typing from ...core import app -from . import loader as tools_loader from ...utils import importutil from . import loaders +from .loaders import mcp as mcp_loader, plugin as plugin_loader import langbot_plugin.api.entities.builtin.resource.tool as resource_tool importutil.import_modules_in_pkg(loaders) @@ -16,25 +16,24 @@ class ToolManager: ap: app.Application - loaders: list[tools_loader.ToolLoader] + plugin_tool_loader: plugin_loader.PluginToolLoader + mcp_tool_loader: mcp_loader.MCPLoader def __init__(self, ap: app.Application): self.ap = ap - self.all_functions = [] - self.loaders = [] async def initialize(self): - for loader_cls in tools_loader.preregistered_loaders: - loader_inst = loader_cls(self.ap) - await loader_inst.initialize() - self.loaders.append(loader_inst) + self.plugin_tool_loader = plugin_loader.PluginToolLoader(self.ap) + await self.plugin_tool_loader.initialize() + self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap) + await self.mcp_tool_loader.initialize() async def get_all_tools(self) -> list[resource_tool.LLMTool]: """获取所有函数""" all_functions: list[resource_tool.LLMTool] = [] - for loader in self.loaders: - all_functions.extend(await loader.get_tools()) + all_functions.extend(await self.plugin_tool_loader.get_tools()) + all_functions.extend(await self.mcp_tool_loader.get_tools()) return all_functions @@ -93,13 +92,14 @@ class ToolManager: async def execute_func_call(self, name: str, parameters: dict) -> typing.Any: """执行函数调用""" - for loader in self.loaders: - if await loader.has_tool(name): - return await loader.invoke_tool(name, parameters) + if await self.plugin_tool_loader.has_tool(name): + return await self.plugin_tool_loader.invoke_tool(name, parameters) + elif await self.mcp_tool_loader.has_tool(name): + return await self.mcp_tool_loader.invoke_tool(name, parameters) else: raise ValueError(f'未找到工具: {name}') async def shutdown(self): """关闭所有工具""" - for loader in self.loaders: - await loader.shutdown() + await self.plugin_tool_loader.shutdown() + await self.mcp_tool_loader.shutdown() diff --git a/templates/config.yaml b/templates/config.yaml index b81b04dc..366ee782 100644 --- a/templates/config.yaml +++ b/templates/config.yaml @@ -10,8 +10,6 @@ command: concurrency: pipeline: 20 session: 1 -mcp: - servers: [] proxy: http: '' https: '' diff --git a/web/package.json b/web/package.json index abdc435b..36e6acf3 100644 --- a/web/package.json +++ b/web/package.json @@ -23,6 +23,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.0.1", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.1", "@radix-ui/react-context-menu": "^2.2.15", "@radix-ui/react-dialog": "^1.1.14", diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index f6aa21c0..d493b8d1 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -115,7 +115,6 @@ export default function BotForm({ useEffect(() => { setBotFormValues(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function setBotFormValues() { diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index b9489668..76f232e4 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -65,7 +65,6 @@ export default function HomeSidebar({ console.error('Failed to fetch GitHub star count:', error); }); return () => console.log('sidebar.unmounted'); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function handleChildClick(child: SidebarChildVO) { diff --git a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx index e2af33a8..612151d9 100644 --- a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx @@ -63,7 +63,6 @@ const PluginInstalledComponent = forwardRef( useEffect(() => { initData(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function initData() { diff --git a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx index dbde8774..b9835253 100644 --- a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx @@ -172,7 +172,6 @@ function MarketPageContent({ // 初始加载 useEffect(() => { fetchPlugins(1, false, true); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 搜索功能 diff --git a/web/src/app/home/plugins/mcp-server/MCPCardVO.ts b/web/src/app/home/plugins/mcp-server/MCPCardVO.ts new file mode 100644 index 00000000..5fe76e13 --- /dev/null +++ b/web/src/app/home/plugins/mcp-server/MCPCardVO.ts @@ -0,0 +1,29 @@ +import { MCPServer, MCPSessionStatus } from '@/app/infra/entities/api'; + +export class MCPCardVO { + name: string; + mode: 'stdio' | 'sse'; + enable: boolean; + status: MCPSessionStatus; + tools: number; + error?: string; + + constructor(data: MCPServer) { + this.name = data.name; + this.mode = data.mode; + this.enable = data.enable; + + // Determine status from runtime_info + if (!data.runtime_info) { + this.status = MCPSessionStatus.ERROR; + this.tools = 0; + } else if (data.runtime_info.status === MCPSessionStatus.CONNECTED) { + this.status = data.runtime_info.status; + this.tools = data.runtime_info.tool_count || 0; + } else { + this.status = data.runtime_info.status; + this.tools = 0; + this.error = data.runtime_info.error_message; + } + } +} diff --git a/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx b/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx new file mode 100644 index 00000000..704e20fc --- /dev/null +++ b/web/src/app/home/plugins/mcp-server/MCPServerComponent.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import MCPCardComponent from '@/app/home/plugins/mcp-server/mcp-card/MCPCardComponent'; +import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO'; +import { useTranslation } from 'react-i18next'; +import { MCPSessionStatus } from '@/app/infra/entities/api'; + +import { httpClient } from '@/app/infra/http/HttpClient'; + +export default function MCPComponent({ + onEditServer, +}: { + askInstallServer?: (githubURL: string) => void; + onEditServer?: (serverName: string) => void; +}) { + const { t } = useTranslation(); + const [installedServers, setInstalledServers] = useState([]); + const [loading, setLoading] = useState(false); + const pollingIntervalRef = useRef(null); + + useEffect(() => { + fetchInstalledServers(); + + return () => { + // Cleanup: clear polling interval when component unmounts + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + } + }; + }, []); + + // Check if any server is connecting and start/stop polling accordingly + useEffect(() => { + const hasConnecting = installedServers.some( + (server) => server.status === MCPSessionStatus.CONNECTING, + ); + + if (hasConnecting && !pollingIntervalRef.current) { + // Start polling every 3 seconds + pollingIntervalRef.current = setInterval(() => { + fetchInstalledServers(); + }, 3000); + } else if (!hasConnecting && pollingIntervalRef.current) { + // Stop polling when no server is connecting + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + + return () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }; + }, [installedServers]); + + function fetchInstalledServers() { + setLoading(true); + httpClient + .getMCPServers() + .then((resp) => { + const servers = resp.servers.map((server) => new MCPCardVO(server)); + setInstalledServers(servers); + setLoading(false); + }) + .catch((error) => { + console.error('Failed to fetch MCP servers:', error); + setLoading(false); + }); + } + + return ( +
+ {/* 已安装的服务器列表 */} +
+ {loading ? ( +
+ {t('mcp.loading')} +
+ ) : installedServers.length === 0 ? ( +
+ + + +
{t('mcp.noServerInstalled')}
+
+ ) : ( +
+ {installedServers.map((server, index) => ( +
+ { + if (onEditServer) { + onEditServer(server.name); + } + }} + onRefresh={fetchInstalledServers} + /> +
+ ))} +
+ )} +
+
+ ); +} diff --git a/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx b/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx new file mode 100644 index 00000000..fd19cd4b --- /dev/null +++ b/web/src/app/home/plugins/mcp-server/mcp-card/MCPCardComponent.tsx @@ -0,0 +1,172 @@ +import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO'; +import { useState, useEffect } from 'react'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { Switch } from '@/components/ui/switch'; +import { Button } from '@/components/ui/button'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { RefreshCcw, Wrench, Ban, AlertCircle, Loader2 } from 'lucide-react'; +import { MCPSessionStatus } from '@/app/infra/entities/api'; + +export default function MCPCardComponent({ + cardVO, + onCardClick, + onRefresh, +}: { + cardVO: MCPCardVO; + onCardClick: () => void; + onRefresh: () => void; +}) { + const { t } = useTranslation(); + const [enabled, setEnabled] = useState(cardVO.enable); + const [switchEnable, setSwitchEnable] = useState(true); + const [testing, setTesting] = useState(false); + const [toolsCount, setToolsCount] = useState(cardVO.tools); + const [status, setStatus] = useState(cardVO.status); + + useEffect(() => { + setStatus(cardVO.status); + setToolsCount(cardVO.tools); + setEnabled(cardVO.enable); + }, [cardVO.status, cardVO.tools, cardVO.enable]); + + function handleEnable(checked: boolean) { + setSwitchEnable(false); + httpClient + .toggleMCPServer(cardVO.name, checked) + .then(() => { + setEnabled(checked); + toast.success(t('mcp.saveSuccess')); + onRefresh(); + setSwitchEnable(true); + }) + .catch((err) => { + toast.error(t('mcp.modifyFailed') + err.message); + setSwitchEnable(true); + }); + } + + function handleTest(e: React.MouseEvent) { + e.stopPropagation(); + setTesting(true); + + httpClient + .testMCPServer(cardVO.name, {}) + .then((resp) => { + const taskId = resp.task_id; + + const interval = setInterval(() => { + httpClient.getAsyncTask(taskId).then((taskResp) => { + if (taskResp.runtime.done) { + clearInterval(interval); + setTesting(false); + + if (taskResp.runtime.exception) { + toast.error( + t('mcp.refreshFailed') + taskResp.runtime.exception, + ); + } else { + toast.success(t('mcp.refreshSuccess')); + } + + // Refresh to get updated runtime_info + onRefresh(); + } + }); + }, 1000); + }) + .catch((err) => { + toast.error(t('mcp.refreshFailed') + err.message); + setTesting(false); + }); + } + + return ( +
+
+ + + + +
+
+
+ {cardVO.name} +
+
+ +
+ {!enabled ? ( + // 未启用 - 橙色 +
+ +
+ {t('mcp.statusDisabled')} +
+
+ ) : status === MCPSessionStatus.CONNECTED ? ( + // 连接成功 - 显示工具数量 +
+ +
+ {t('mcp.toolCount', { count: toolsCount })} +
+
+ ) : status === MCPSessionStatus.CONNECTING ? ( + // 连接中 - 蓝色加载 +
+ +
+ {t('mcp.connecting')} +
+
+ ) : ( + // 连接失败 - 红色 +
+ +
+ {t('mcp.connectionFailed')} +
+
+ )} +
+
+ +
+
e.stopPropagation()} + > + +
+ +
+ +
+
+
+
+ ); +} diff --git a/web/src/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog.tsx b/web/src/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog.tsx new file mode 100644 index 00000000..cf40a20c --- /dev/null +++ b/web/src/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { httpClient } from '@/app/infra/http/HttpClient'; + +interface MCPDeleteConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + serverName: string | null; + onSuccess?: () => void; +} + +export default function MCPDeleteConfirmDialog({ + open, + onOpenChange, + serverName, + onSuccess, +}: MCPDeleteConfirmDialogProps) { + const { t } = useTranslation(); + + async function handleDelete() { + if (!serverName) return; + + try { + await httpClient.deleteMCPServer(serverName); + toast.success(t('mcp.deleteSuccess')); + + onOpenChange(false); + + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error('Failed to delete server:', error); + toast.error(t('mcp.deleteFailed')); + } + } + + return ( + + + + {t('mcp.confirmDeleteTitle')} + + {t('mcp.confirmDeleteServer')} + + + + + + + ); +} diff --git a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx new file mode 100644 index 00000000..4638bd1e --- /dev/null +++ b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx @@ -0,0 +1,666 @@ +'use client'; + +import React, { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Resolver, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, +} from '@/components/ui/card'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { + MCPServerRuntimeInfo, + MCPTool, + MCPServer, + MCPSessionStatus, +} from '@/app/infra/entities/api'; + +// Status Display Component - 在测试中、连接中或连接失败时使用 +function StatusDisplay({ + testing, + runtimeInfo, + t, +}: { + testing: boolean; + runtimeInfo: MCPServerRuntimeInfo; + t: (key: string) => string; +}) { + if (testing) { + return ( +
+ + + + + {t('mcp.testing')} +
+ ); + } + + // 连接中 + if (runtimeInfo.status === MCPSessionStatus.CONNECTING) { + return ( +
+ + + + + {t('mcp.connecting')} +
+ ); + } + + // 连接失败 + return ( +
+
+ + + + {t('mcp.connectionFailed')} +
+ {runtimeInfo.error_message && ( +
+ {runtimeInfo.error_message} +
+ )} +
+ ); +} + +// Tools List Component +function ToolsList({ tools }: { tools: MCPTool[] }) { + return ( +
+ {tools.map((tool, index) => ( + + + {tool.name} + {tool.description && ( + + {tool.description} + + )} + + + ))} +
+ ); +} + +const getFormSchema = (t: (key: string) => string) => + z.object({ + name: z + .string({ required_error: t('mcp.nameRequired') }) + .min(1, { message: t('mcp.nameRequired') }), + timeout: z + .number({ invalid_type_error: t('mcp.timeoutMustBeNumber') }) + .positive({ message: t('mcp.timeoutMustBePositive') }) + .default(30), + ssereadtimeout: z + .number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') }) + .positive({ message: t('mcp.timeoutMustBePositive') }) + .default(300), + url: z + .string({ required_error: t('mcp.urlRequired') }) + .min(1, { message: t('mcp.urlRequired') }), + extra_args: z + .array( + z.object({ + key: z.string(), + type: z.enum(['string', 'number', 'boolean']), + value: z.string(), + }), + ) + .optional(), + }); + +type FormValues = z.infer> & { + timeout: number; + ssereadtimeout: number; +}; + +interface MCPFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + serverName?: string | null; + isEditMode?: boolean; + onSuccess?: () => void; + onDelete?: () => void; +} + +export default function MCPFormDialog({ + open, + onOpenChange, + serverName, + isEditMode = false, + onSuccess, + onDelete, +}: MCPFormDialogProps) { + const { t } = useTranslation(); + const formSchema = getFormSchema(t); + + const form = useForm({ + resolver: zodResolver(formSchema) as unknown as Resolver, + defaultValues: { + name: '', + url: '', + timeout: 30, + ssereadtimeout: 300, + extra_args: [], + }, + }); + + const [extraArgs, setExtraArgs] = useState< + { key: string; type: 'string' | 'number' | 'boolean'; value: string }[] + >([]); + const [mcpTesting, setMcpTesting] = useState(false); + const [runtimeInfo, setRuntimeInfo] = useState( + null, + ); + const pollingIntervalRef = useRef(null); + + // Load server data when editing + useEffect(() => { + if (open && isEditMode && serverName) { + loadServerForEdit(serverName); + } else if (open && !isEditMode) { + // Reset form when creating new server + form.reset(); + setExtraArgs([]); + setRuntimeInfo(null); + } + + // Cleanup polling interval when dialog closes + return () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }; + }, [open, isEditMode, serverName]); + + // Poll for updates when runtime_info status is CONNECTING + useEffect(() => { + if ( + !open || + !isEditMode || + !serverName || + !runtimeInfo || + runtimeInfo.status !== MCPSessionStatus.CONNECTING + ) { + // Stop polling if conditions are not met + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + return; + } + + // Start polling if not already running + if (!pollingIntervalRef.current) { + pollingIntervalRef.current = setInterval(() => { + loadServerForEdit(serverName); + }, 3000); + } + + return () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }; + }, [open, isEditMode, serverName, runtimeInfo?.status]); + + async function loadServerForEdit(serverName: string) { + try { + const resp = await httpClient.getMCPServer(serverName); + const server = resp.server ?? resp; + + const extraArgs = server.extra_args; + form.setValue('name', server.name); + form.setValue('url', extraArgs.url); + form.setValue('timeout', extraArgs.timeout); + form.setValue('ssereadtimeout', extraArgs.ssereadtimeout); + + if (extraArgs.headers) { + const headers = Object.entries(extraArgs.headers).map( + ([key, value]) => ({ + key, + type: 'string' as const, + value: String(value), + }), + ); + setExtraArgs(headers); + form.setValue('extra_args', headers); + } + + // Set runtime_info from server data + if (server.runtime_info) { + setRuntimeInfo(server.runtime_info); + } else { + setRuntimeInfo(null); + } + } catch (error) { + console.error('Failed to load server:', error); + toast.error(t('mcp.loadFailed')); + } + } + + async function handleFormSubmit(value: z.infer) { + // Convert extra_args to headers - all values must be strings according to MCPServerExtraArgsSSE + const headers: Record = {}; + value.extra_args?.forEach((arg) => { + // Convert all values to strings to match MCPServerExtraArgsSSE.headers type + headers[arg.key] = String(arg.value); + }); + + try { + const serverConfig: Omit< + MCPServer, + 'uuid' | 'created_at' | 'updated_at' | 'runtime_info' + > = { + name: value.name, + mode: 'sse' as const, + enable: true, + extra_args: { + url: value.url, + headers: headers, + timeout: value.timeout, + ssereadtimeout: value.ssereadtimeout, + }, + }; + + if (isEditMode && serverName) { + await httpClient.updateMCPServer(serverName, serverConfig); + toast.success(t('mcp.updateSuccess')); + } else { + await httpClient.createMCPServer(serverConfig); + toast.success(t('mcp.createSuccess')); + } + + handleDialogClose(false); + onSuccess?.(); + } catch (error) { + console.error('Failed to save MCP server:', error); + toast.error(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')); + } + } + + async function testMcp() { + setMcpTesting(true); + + try { + const { task_id } = await httpClient.testMCPServer('_', { + name: form.getValues('name'), + mode: 'sse', + enable: true, + extra_args: { + url: form.getValues('url'), + timeout: form.getValues('timeout'), + ssereadtimeout: form.getValues('ssereadtimeout'), + headers: Object.fromEntries( + extraArgs.map((arg) => [arg.key, arg.value]), + ), + }, + }); + if (!task_id) { + throw new Error(t('mcp.noTaskId')); + } + + const interval = setInterval(async () => { + try { + const taskResp = await httpClient.getAsyncTask(task_id); + + if (taskResp.runtime?.done) { + clearInterval(interval); + setMcpTesting(false); + + if (taskResp.runtime.exception) { + const errorMsg = + taskResp.runtime.exception || t('mcp.unknownError'); + toast.error(`${t('mcp.testError')}: ${errorMsg}`); + setRuntimeInfo({ + status: MCPSessionStatus.ERROR, + error_message: errorMsg, + tool_count: 0, + tools: [], + }); + } else { + if (isEditMode) { + await loadServerForEdit(form.getValues('name')); + } + toast.success(t('mcp.testSuccess')); + } + } + } catch (err) { + clearInterval(interval); + setMcpTesting(false); + const errorMsg = (err as Error).message || t('mcp.getTaskFailed'); + toast.error(`${t('mcp.testError')}: ${errorMsg}`); + } + }, 1000); + } catch (err) { + setMcpTesting(false); + const errorMsg = (err as Error).message || t('mcp.unknownError'); + toast.error(`${t('mcp.testError')}: ${errorMsg}`); + } + } + + const addExtraArg = () => { + const newArgs = [ + ...extraArgs, + { key: '', type: 'string' as const, value: '' }, + ]; + setExtraArgs(newArgs); + form.setValue('extra_args', newArgs); + }; + + const removeExtraArg = (index: number) => { + const newArgs = extraArgs.filter((_, i) => i !== index); + setExtraArgs(newArgs); + form.setValue('extra_args', newArgs); + }; + + const updateExtraArg = ( + index: number, + field: 'key' | 'type' | 'value', + value: string, + ) => { + const newArgs = [...extraArgs]; + newArgs[index] = { ...newArgs[index], [field]: value }; + setExtraArgs(newArgs); + form.setValue('extra_args', newArgs); + }; + + const handleDialogClose = (open: boolean) => { + onOpenChange(open); + if (!open) { + form.reset(); + setExtraArgs([]); + setRuntimeInfo(null); + } + }; + + return ( + + + + + {isEditMode ? t('mcp.editServer') : t('mcp.createServer')} + + + + {isEditMode && runtimeInfo && ( +
+ {/* 测试中或连接失败时显示状态 */} + {(mcpTesting || + runtimeInfo.status !== MCPSessionStatus.CONNECTED) && ( +
+ +
+ )} + + {/* 连接成功时只显示工具列表 */} + {!mcpTesting && + runtimeInfo.status === MCPSessionStatus.CONNECTED && + runtimeInfo.tools?.length > 0 && ( + + )} +
+ )} + +
+ +
+ ( + + {t('mcp.name')} + + + + + + )} + /> + + ( + + {t('mcp.url')} + + + + + + )} + /> + + ( + + {t('mcp.timeout')} + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + + ( + + {t('mcp.sseTimeout')} + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + + + {t('models.extraParameters')} +
+ {extraArgs.map((arg, index) => ( +
+ + updateExtraArg(index, 'key', e.target.value) + } + /> + + + updateExtraArg(index, 'value', e.target.value) + } + /> + +
+ ))} + +
+ + {t('mcp.extraParametersDescription')} + + +
+ + + {isEditMode && onDelete && ( + + )} + + + + + + + +
+
+ +
+
+ ); +} diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 8a97ab26..fa9d0980 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -3,7 +3,9 @@ import PluginInstalledComponent, { PluginInstalledComponentRef, } from '@/app/home/plugins/components/plugin-installed/PluginInstalledComponent'; import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent'; -// import PluginSortDialog from '@/app/home/plugins/plugin-sort/PluginSortDialog'; +import MCPServerComponent from '@/app/home/plugins/mcp-server/MCPServerComponent'; +import MCPFormDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPFormDialog'; +import MCPDeleteConfirmDialog from '@/app/home/plugins/mcp-server/mcp-form/MCPDeleteConfirmDialog'; import styles from './plugins.module.css'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; @@ -29,7 +31,7 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; -import { useState, useRef, useCallback, useEffect } from 'react'; +import React, { useState, useRef, useCallback, useEffect } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; @@ -46,11 +48,11 @@ enum PluginInstallStatus { export default function PluginConfigPage() { const { t } = useTranslation(); - const [modalOpen, setModalOpen] = useState(false); - // const [sortModalOpen, setSortModalOpen] = useState(false); const [activeTab, setActiveTab] = useState('installed'); + const [modalOpen, setModalOpen] = useState(false); const [installSource, setInstallSource] = useState('local'); const [installInfo, setInstallInfo] = useState>({}); // eslint-disable-line @typescript-eslint/no-explicit-any + const [mcpSSEModalOpen, setMcpSSEModalOpen] = useState(false); const [pluginInstallStatus, setPluginInstallStatus] = useState(PluginInstallStatus.WAIT_INPUT); const [installError, setInstallError] = useState(null); @@ -59,8 +61,13 @@ export default function PluginConfigPage() { const [pluginSystemStatus, setPluginSystemStatus] = useState(null); const [statusLoading, setStatusLoading] = useState(true); - const pluginInstalledRef = useRef(null); const fileInputRef = useRef(null); + const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); + const [editingServerName, setEditingServerName] = useState( + null, + ); + const [isEditMode, setIsEditMode] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); useEffect(() => { const fetchPluginSystemStatus = async () => { @@ -81,19 +88,15 @@ export default function PluginConfigPage() { function watchTask(taskId: number) { let alreadySuccess = false; - console.log('taskId:', taskId); - // 每秒拉取一次任务状态 const interval = setInterval(() => { httpClient.getAsyncTask(taskId).then((resp) => { - console.log('task status:', resp); if (resp.runtime.done) { clearInterval(interval); if (resp.runtime.exception) { setInstallError(resp.runtime.exception); setPluginInstallStatus(PluginInstallStatus.ERROR); } else { - // success if (!alreadySuccess) { toast.success(t('plugins.installSuccess')); alreadySuccess = true; @@ -107,52 +110,54 @@ export default function PluginConfigPage() { }, 1000); } + const pluginInstalledRef = useRef(null); + function handleModalConfirm() { - installPlugin(installSource, installInfo as Record); // eslint-disable-line @typescript-eslint/no-explicit-any + installPlugin(installSource, installInfo as Record); } - function installPlugin( - installSource: string, - installInfo: Record, // eslint-disable-line @typescript-eslint/no-explicit-any - ) { - setPluginInstallStatus(PluginInstallStatus.INSTALLING); - if (installSource === 'github') { - httpClient - .installPluginFromGithub(installInfo.url) - .then((resp) => { - const taskId = resp.task_id; - watchTask(taskId); - }) - .catch((err) => { - console.log('error when install plugin:', err); - setInstallError(err.message); - setPluginInstallStatus(PluginInstallStatus.ERROR); - }); - } else if (installSource === 'local') { - httpClient - .installPluginFromLocal(installInfo.file) - .then((resp) => { - const taskId = resp.task_id; - watchTask(taskId); - }) - .catch((err) => { - console.log('error when install plugin:', err); - setInstallError(err.message); - setPluginInstallStatus(PluginInstallStatus.ERROR); - }); - } else if (installSource === 'marketplace') { - httpClient - .installPluginFromMarketplace( - installInfo.plugin_author, - installInfo.plugin_name, - installInfo.plugin_version, - ) - .then((resp) => { - const taskId = resp.task_id; - watchTask(taskId); - }); - } - } + const installPlugin = useCallback( + (installSource: string, installInfo: Record) => { + setPluginInstallStatus(PluginInstallStatus.INSTALLING); + if (installSource === 'github') { + httpClient + .installPluginFromGithub((installInfo as { url: string }).url) + .then((resp) => { + const taskId = resp.task_id; + watchTask(taskId); + }) + .catch((err) => { + console.log('error when install plugin:', err); + setInstallError(err.message); + setPluginInstallStatus(PluginInstallStatus.ERROR); + }); + } else if (installSource === 'local') { + httpClient + .installPluginFromLocal((installInfo as { file: File }).file) + .then((resp) => { + const taskId = resp.task_id; + watchTask(taskId); + }) + .catch((err) => { + console.log('error when install plugin:', err); + setInstallError(err.message); + setPluginInstallStatus(PluginInstallStatus.ERROR); + }); + } else if (installSource === 'marketplace') { + httpClient + .installPluginFromMarketplace( + (installInfo as { plugin_author: string }).plugin_author, + (installInfo as { plugin_name: string }).plugin_name, + (installInfo as { plugin_version: string }).plugin_version, + ) + .then((resp) => { + const taskId = resp.task_id; + watchTask(taskId); + }); + } + }, + [watchTask], + ); const validateFileType = (file: File): boolean => { const allowedExtensions = ['.lbpkg', '.zip']; @@ -177,7 +182,7 @@ export default function PluginConfigPage() { setInstallError(null); installPlugin('local', { file }); }, - [t, pluginSystemStatus], + [t, pluginSystemStatus, installPlugin], ); const handleFileSelect = useCallback(() => { @@ -192,7 +197,7 @@ export default function PluginConfigPage() { if (file) { uploadPluginFile(file); } - // 清空input值,以便可以重复选择同一个文件 + event.target.value = ''; }, [uploadPluginFile], @@ -234,7 +239,6 @@ export default function PluginConfigPage() { [uploadPluginFile, isPluginSystemReady, t], ); - // 插件系统未启用的状态显示 const renderPluginDisabledState = () => (
@@ -247,7 +251,6 @@ export default function PluginConfigPage() {
); - // 插件系统连接异常的状态显示 const renderPluginConnectionErrorState = () => (
); - // 加载状态显示 const renderLoadingState = () => (

@@ -278,7 +280,6 @@ export default function PluginConfigPage() {

); - // 根据状态返回不同的内容 if (statusLoading) { return renderLoadingState(); } @@ -316,40 +317,57 @@ export default function PluginConfigPage() { {t('plugins.marketplace')} )} + + {t('mcp.title')} +
- {/* */} - - - {t('plugins.uploadLocal')} - - {systemInfo.enable_marketplace && ( - { - setActiveTab('market'); - }} - > - - {t('plugins.marketplace')} - + {activeTab === 'mcp-servers' ? ( + <> + { + setActiveTab('mcp-servers'); + setIsEditMode(false); + setEditingServerName(null); + setMcpSSEModalOpen(true); + }} + > + + {t('mcp.createServer')} + + + ) : ( + <> + + + {t('plugins.uploadLocal')} + + {systemInfo.enable_marketplace && ( + { + setActiveTab('market'); + }} + > + + {t('plugins.marketplace')} + + )} + )} @@ -372,6 +390,16 @@ export default function PluginConfigPage() { }} /> + + { + setEditingServerName(serverName); + setIsEditMode(true); + setMcpSSEModalOpen(true); + }} + /> + @@ -435,7 +463,6 @@ export default function PluginConfigPage() { - {/* 拖拽提示覆盖层 */} {isDragOver && (
@@ -449,13 +476,32 @@ export default function PluginConfigPage() {
)} - {/* { - pluginInstalledRef.current?.refreshPluginList(); + { + setEditingServerName(null); + setIsEditMode(false); + setRefreshKey((prev) => prev + 1); }} - /> */} + onDelete={() => { + setShowDeleteConfirmModal(true); + }} + /> + + { + setMcpSSEModalOpen(false); + setEditingServerName(null); + setIsEditMode(false); + setRefreshKey((prev) => prev + 1); + }} + />
); } diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 31ffdc23..152828fd 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -308,3 +308,49 @@ export interface RetrieveResult { export interface ApiRespKnowledgeBaseRetrieve { results: RetrieveResult[]; } + +// MCP +export interface ApiRespMCPServers { + servers: MCPServer[]; +} + +export interface ApiRespMCPServer { + server: MCPServer; +} + +export interface MCPServerExtraArgsSSE { + url: string; + headers: Record; + timeout: number; + ssereadtimeout: number; +} + +export enum MCPSessionStatus { + CONNECTING = 'connecting', + CONNECTED = 'connected', + ERROR = 'error', +} + +export interface MCPServerRuntimeInfo { + status: MCPSessionStatus; + error_message: string; + tool_count: number; + tools: MCPTool[]; +} + +export interface MCPServer { + uuid?: string; + name: string; + mode: 'stdio' | 'sse'; + enable: boolean; + extra_args: MCPServerExtraArgsSSE; + runtime_info?: MCPServerRuntimeInfo; + created_at?: string; + updated_at?: string; +} + +export interface MCPTool { + name: string; + description: string; + parameters?: object; +} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 84492990..319e2bc5 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -33,6 +33,9 @@ import { ApiRespProviderEmbeddingModel, EmbeddingModel, ApiRespPluginSystemStatus, + ApiRespMCPServers, + ApiRespMCPServer, + MCPServer, } from '@/app/infra/entities/api'; import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest'; import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse'; @@ -491,6 +494,58 @@ export class BackendClient extends BaseHttpClient { return this.post(`/api/v1/plugins/${author}/${name}/upgrade`); } + // ============ MCP API ============ + public getMCPServers(): Promise { + return this.get('/api/v1/mcp/servers'); + } + + public getMCPServer(serverName: string): Promise { + return this.get(`/api/v1/mcp/servers/${serverName}`); + } + + public createMCPServer(server: MCPServer): Promise { + return this.post('/api/v1/mcp/servers', server); + } + + public updateMCPServer( + serverName: string, + server: Partial, + ): Promise { + return this.put(`/api/v1/mcp/servers/${serverName}`, server); + } + + public deleteMCPServer(serverName: string): Promise { + return this.delete(`/api/v1/mcp/servers/${serverName}`); + } + + public toggleMCPServer( + serverName: string, + target_enabled: boolean, + ): Promise { + return this.put(`/api/v1/mcp/servers/${serverName}`, { + enable: target_enabled, + }); + } + + public testMCPServer( + serverName: string, + serverData: object, + ): Promise { + return this.post(`/api/v1/mcp/servers/${serverName}/test`, serverData); + } + + public installMCPServerFromGithub( + source: string, + ): Promise { + return this.post('/api/v1/mcp/install/github', { source }); + } + + public installMCPServerFromSSE( + source: object, + ): Promise { + return this.post('/api/v1/mcp/servers', { source }); + } + // ============ System API ============ public getSystemInfo(): Promise { return this.get('/api/v1/system/info'); diff --git a/web/src/components/ui/alert-dialog.tsx b/web/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..d8d2f15d --- /dev/null +++ b/web/src/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +'use client'; + +import * as React from 'react'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; + +import { cn } from '@/lib/utils'; +import { buttonVariants } from '@/components/ui/button'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx index 7caae6a2..743c4b87 100644 --- a/web/src/components/ui/dialog.tsx +++ b/web/src/components/ui/dialog.tsx @@ -7,9 +7,71 @@ import { XIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; function Dialog({ + onOpenChange, + open, ...props }: React.ComponentProps) { - return ; + const handleOpenChange = React.useCallback( + (isOpen: boolean) => { + onOpenChange?.(isOpen); + + // 当对话框关闭时,确保清理 body 样式 + if (!isOpen) { + // 立即清理 + document.body.style.removeProperty('pointer-events'); + document.body.style.removeProperty('overflow'); + + // 延迟再次清理,确保覆盖 Radix 的设置 + setTimeout(() => { + document.body.style.removeProperty('pointer-events'); + document.body.style.removeProperty('overflow'); + }, 0); + + setTimeout(() => { + document.body.style.removeProperty('pointer-events'); + document.body.style.removeProperty('overflow'); + }, 50); + + setTimeout(() => { + document.body.style.removeProperty('pointer-events'); + document.body.style.removeProperty('overflow'); + }, 150); + } + }, + [onOpenChange], + ); + + // 使用 effect 监控 open 状态变化 + React.useEffect(() => { + if (open === false) { + const cleanup = () => { + document.body.style.removeProperty('pointer-events'); + document.body.style.removeProperty('overflow'); + }; + + cleanup(); + const timer1 = setTimeout(cleanup, 0); + const timer2 = setTimeout(cleanup, 50); + const timer3 = setTimeout(cleanup, 150); + const timer4 = setTimeout(cleanup, 300); + + return () => { + clearTimeout(timer1); + clearTimeout(timer2); + clearTimeout(timer3); + clearTimeout(timer4); + }; + } + }, [open]); + + return ( + + ); } function DialogTrigger({ @@ -60,7 +122,6 @@ function DialogContent({ 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', className, )} - onInteractOutside={() => {}} {...props} > {children} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index ed72e36c..b81f691e 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -151,7 +151,7 @@ const enUS = { logs: 'Logs', }, plugins: { - title: 'Plugins', + title: 'Extensions', description: 'Install and configure plugins to extend LangBot functionality', createPlugin: 'Create Plugin', @@ -286,6 +286,83 @@ const enUS = { markAsReadSuccess: 'Marked as read', markAsReadFailed: 'Mark as read failed', }, + mcp: { + title: 'MCP', + createServer: 'Add MCP Server', + editServer: 'Edit MCP Server', + deleteServer: 'Delete MCP Server', + confirmDeleteServer: 'Are you sure you want to delete this MCP server?', + confirmDeleteTitle: 'Delete MCP Server', + getServerListError: 'Failed to get MCP server list: ', + serverName: 'Server Name', + serverMode: 'Connection Mode', + stdio: 'Stdio Mode', + sse: 'SSE Mode', + noServerInstalled: 'No MCP servers configured', + serverNameRequired: 'Server name cannot be empty', + commandRequired: 'Command cannot be empty', + urlRequired: 'URL cannot be empty', + timeoutMustBePositive: 'Timeout must be a positive number', + command: 'Command', + args: 'Arguments', + env: 'Environment Variables', + url: 'URL', + headers: 'Headers', + timeout: 'Timeout', + addArgument: 'Add Argument', + addEnvVar: 'Add Environment Variable', + addHeader: 'Add Header', + keyName: 'Key Name', + value: 'Value', + testing: 'Testing...', + connecting: 'Connecting...', + testSuccess: 'Test successful', + testFailed: 'Test failed: ', + testError: 'Test error', + refreshSuccess: 'Refresh successful', + refreshFailed: 'Refresh failed: ', + connectionSuccess: 'Connection successful', + connectionFailed: 'Connection failed', + toolsFound: 'tools', + unknownError: 'Unknown error', + noToolsFound: 'No tools found', + parseResultFailed: 'Failed to parse test result', + noResultReturned: 'Test returned no result', + getTaskFailed: 'Failed to get task status', + noTaskId: 'No task ID obtained', + deleteSuccess: 'Deleted successfully', + deleteFailed: 'Delete failed: ', + deleteError: 'Delete failed: ', + saveSuccess: 'Saved successfully', + saveError: 'Save failed: ', + createSuccess: 'Created successfully', + createFailed: 'Creation failed: ', + createError: 'Creation failed: ', + loadFailed: 'Load failed', + modifyFailed: 'Modify failed: ', + toolCount: 'Tools: {{count}}', + statusConnected: 'Connected', + statusDisconnected: 'Disconnected', + statusError: 'Connection Error', + statusDisabled: 'Disabled', + loading: 'Loading...', + starCount: 'Stars: {{count}}', + install: 'Install', + installFromGithub: 'Install MCP Server from GitHub', + add: 'Add', + name: 'Name', + nameRequired: 'Name cannot be empty', + sseTimeout: 'SSE Timeout', + sseTimeoutDescription: 'Timeout for establishing SSE connection', + extraParametersDescription: + 'Additional parameters for configuring specific MCP server behavior', + timeoutMustBeNumber: 'Timeout must be a number', + timeoutNonNegative: 'Timeout cannot be negative', + sseTimeoutMustBeNumber: 'SSE timeout must be a number', + sseTimeoutNonNegative: 'SSE timeout cannot be negative', + updateSuccess: 'Updated successfully', + updateFailed: 'Update failed: ', + }, pipelines: { title: 'Pipelines', description: diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index af366d9b..d87b20f8 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -153,7 +153,7 @@ const jaJP = { logs: 'ログ', }, plugins: { - title: 'プラグイン', + title: '拡張機能', description: 'LangBotの機能を拡張するプラグインをインストール・設定', createPlugin: 'プラグインを作成', editPlugin: 'プラグインを編集', @@ -287,6 +287,83 @@ const jaJP = { markAsReadSuccess: '既読に設定しました', markAsReadFailed: '既読に設定に失敗しました', }, + mcp: { + title: 'MCP', + createServer: 'MCPサーバーを追加', + editServer: 'MCPサーバーを編集', + deleteServer: 'MCPサーバーを削除', + confirmDeleteServer: 'このMCPサーバーを削除してもよろしいですか?', + confirmDeleteTitle: 'MCPサーバーを削除', + getServerListError: 'MCPサーバーリストの取得に失敗しました:', + serverName: 'サーバー名', + serverMode: '接続モード', + stdio: 'Stdioモード', + sse: 'SSEモード', + noServerInstalled: 'MCPサーバーが設定されていません', + serverNameRequired: 'サーバー名は必須です', + commandRequired: 'コマンドは必須です', + urlRequired: 'URLは必須です', + timeoutMustBePositive: 'タイムアウトは正の数でなければなりません', + command: 'コマンド', + args: '引数', + env: '環境変数', + url: 'URL', + headers: 'ヘッダー', + timeout: 'タイムアウト', + addArgument: '引数を追加', + addEnvVar: '環境変数を追加', + addHeader: 'ヘッダーを追加', + keyName: 'キー名', + value: '値', + testing: 'テスト中...', + connecting: '接続中...', + testSuccess: '刷新に成功しました', + testFailed: '刷新に失敗しました:', + testError: '刷新エラー', + refreshSuccess: '刷新に成功しました', + refreshFailed: '刷新に失敗しました:', + connectionSuccess: '接続に成功しました', + connectionFailed: '接続に失敗しました', + toolsFound: '個のツール', + unknownError: '不明なエラー', + noToolsFound: 'ツールが見つかりません', + parseResultFailed: 'テスト結果の解析に失敗しました', + noResultReturned: 'テスト結果が返されませんでした', + getTaskFailed: 'タスクステータスの取得に失敗しました', + noTaskId: 'タスクIDを取得できませんでした', + deleteSuccess: '削除に成功しました', + deleteFailed: '削除に失敗しました:', + deleteError: '削除に失敗しました:', + saveSuccess: '保存に成功しました', + saveError: '保存に失敗しました:', + createSuccess: '作成に成功しました', + createFailed: '作成に失敗しました:', + createError: '作成に失敗しました:', + loadFailed: '読み込みに失敗しました', + modifyFailed: '変更に失敗しました:', + toolCount: 'ツール:{{count}}', + statusConnected: '接続済み', + statusDisconnected: '未接続', + statusError: '接続エラー', + statusDisabled: '無効', + loading: '読み込み中...', + starCount: 'スター:{{count}}', + install: 'インストール', + installFromGithub: 'GitHubからMCPサーバーをインストール', + add: '追加', + name: '名前', + nameRequired: '名前は必須です', + sseTimeout: 'SSEタイムアウト', + sseTimeoutDescription: 'SSE接続を確立するためのタイムアウト', + extraParametersDescription: + 'MCPサーバーの特定の動作を設定するための追加パラメータ', + timeoutMustBeNumber: 'タイムアウトは数値である必要があります', + timeoutNonNegative: 'タイムアウトは負の数にできません', + sseTimeoutMustBeNumber: 'SSEタイムアウトは数値である必要があります', + sseTimeoutNonNegative: 'SSEタイムアウトは負の数にできません', + updateSuccess: '更新に成功しました', + updateFailed: '更新に失敗しました:', + }, pipelines: { title: 'パイプライン', description: diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 8c1ac3d3..dbab2842 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -148,7 +148,7 @@ const zhHans = { logs: '日志', }, plugins: { - title: '插件管理', + title: '插件扩展', description: '安装和配置用于扩展 LangBot 功能的插件', createPlugin: '创建插件', editPlugin: '编辑插件', @@ -272,6 +272,82 @@ const zhHans = { markAsReadSuccess: '已标记为已读', markAsReadFailed: '标记为已读失败', }, + mcp: { + title: 'MCP', + createServer: '添加 MCP 服务器', + editServer: '修改 MCP 服务器', + deleteServer: '删除 MCP 服务器', + confirmDeleteServer: '你确定要删除此 MCP 服务器吗?', + confirmDeleteTitle: '删除 MCP 服务器', + getServerListError: '获取 MCP 服务器列表失败:', + serverName: '服务器名称', + serverMode: '连接模式', + stdio: 'Stdio模式', + sse: 'SSE模式', + noServerInstalled: '暂未配置任何 MCP 服务器', + serverNameRequired: '服务器名称不能为空', + commandRequired: '命令不能为空', + urlRequired: 'URL 不能为空', + timeoutMustBePositive: '超时时间必须是正数', + command: '命令', + args: '参数', + env: '环境变量', + url: 'URL地址', + headers: '请求头', + timeout: '超时时间', + addArgument: '添加参数', + addEnvVar: '添加环境变量', + addHeader: '添加请求头', + keyName: '键名', + value: '值', + testing: '测试中...', + connecting: '连接中...', + testSuccess: '测试成功', + testFailed: '测试失败:', + testError: '刷新出错', + refreshSuccess: '刷新成功', + refreshFailed: '刷新失败:', + connectionSuccess: '连接成功', + connectionFailed: '连接失败', + toolsFound: '个工具', + unknownError: '未知错误', + noToolsFound: '未找到任何工具', + parseResultFailed: '解析测试结果失败', + noResultReturned: '测试未返回结果', + getTaskFailed: '获取任务状态失败', + noTaskId: '未获取到任务ID', + deleteSuccess: '删除成功', + deleteFailed: '删除失败:', + deleteError: '删除失败:', + saveSuccess: '保存成功', + saveError: '保存失败:', + createSuccess: '创建成功', + createFailed: '创建失败:', + createError: '创建失败:', + loadFailed: '加载失败', + modifyFailed: '修改失败:', + toolCount: '工具:{{count}}', + statusConnected: '已打开', + statusDisconnected: '未打开', + statusError: '连接错误', + statusDisabled: '已禁用', + loading: '加载中...', + starCount: '星标:{{count}}', + install: '安装', + installFromGithub: '从Github安装MCP服务器', + add: '添加', + name: '名称', + nameRequired: '名称不能为空', + sseTimeout: 'SSE超时时间', + sseTimeoutDescription: '用于建立SSE连接的超时时间', + extraParametersDescription: '额外参数,用于配置MCP服务器的特定行为', + timeoutMustBeNumber: '超时时间必须是数字', + timeoutNonNegative: '超时时间不能为负数', + sseTimeoutMustBeNumber: 'SSE超时时间必须是数字', + sseTimeoutNonNegative: 'SSE超时时间不能为负数', + updateSuccess: '更新成功', + updateFailed: '更新失败:', + }, pipelines: { title: '流水线', description: '流水线定义了对消息事件的处理流程,用于绑定到机器人', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index ab3ce9a6..8fbe3daa 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -148,7 +148,7 @@ const zhHant = { logs: '日誌', }, plugins: { - title: '外掛管理', + title: '外掛擴展', description: '安裝和設定用於擴展 LangBot 功能的外掛', createPlugin: '建立外掛', editPlugin: '編輯外掛', @@ -271,6 +271,82 @@ const zhHant = { markAsReadSuccess: '已標記為已讀', markAsReadFailed: '標記為已讀失敗', }, + mcp: { + title: 'MCP', + createServer: '新增MCP伺服器', + editServer: '編輯MCP伺服器', + deleteServer: '刪除MCP伺服器', + confirmDeleteServer: '您確定要刪除此MCP伺服器嗎?', + confirmDeleteTitle: '刪除MCP伺服器', + getServerListError: '取得MCP伺服器清單失敗:', + serverName: '伺服器名稱', + serverMode: '連接模式', + stdio: 'Stdio模式', + sse: 'SSE模式', + noServerInstalled: '暫未設定任何MCP伺服器', + serverNameRequired: '伺服器名稱不能為空', + commandRequired: '命令不能為空', + urlRequired: 'URL不能為空', + timeoutMustBePositive: '逾時時間必須是正數', + command: '命令', + args: '參數', + env: '環境變數', + url: 'URL位址', + headers: '請求標頭', + timeout: '逾時時間', + addArgument: '新增參數', + addEnvVar: '新增環境變數', + addHeader: '新增請求標頭', + keyName: '鍵名', + value: '值', + testing: '測試中...', + connecting: '連接中...', + testSuccess: '測試成功', + testFailed: '刷新失敗:', + testError: '刷新出錯', + refreshSuccess: '刷新成功', + refreshFailed: '刷新失敗:', + connectionSuccess: '連接成功', + connectionFailed: '連接失敗', + toolsFound: '個工具', + unknownError: '未知錯誤', + noToolsFound: '未找到任何工具', + parseResultFailed: '解析測試結果失敗', + noResultReturned: '測試未返回結果', + getTaskFailed: '獲取任務狀態失敗', + noTaskId: '未獲取到任務ID', + deleteSuccess: '刪除成功', + deleteFailed: '刪除失敗:', + deleteError: '刪除失敗:', + saveSuccess: '儲存成功', + saveError: '儲存失敗:', + createSuccess: '建立成功', + createFailed: '建立失敗:', + createError: '建立失敗:', + loadFailed: '載入失敗', + modifyFailed: '修改失敗:', + toolCount: '工具:{{count}}', + statusConnected: '已開啟', + statusDisconnected: '未開啟', + statusError: '連接錯誤', + statusDisabled: '已停用', + loading: '載入中...', + starCount: '星標:{{count}}', + install: '安裝', + installFromGithub: '從Github安裝MCP伺服器', + add: '新增', + name: '名稱', + nameRequired: '名稱不能為空', + sseTimeout: 'SSE逾時時間', + sseTimeoutDescription: '用於建立SSE連接的逾時時間', + extraParametersDescription: '額外參數,用於設定MCP伺服器的特定行為', + timeoutMustBeNumber: '逾時時間必須是數字', + timeoutNonNegative: '逾時時間不能為負數', + sseTimeoutMustBeNumber: 'SSE逾時時間必須是數字', + sseTimeoutNonNegative: 'SSE逾時時間不能為負數', + updateSuccess: '更新成功', + updateFailed: '更新失敗:', + }, pipelines: { title: '流程線', description: '流程線定義了對訊息事件的處理流程,用於綁定到機器人',