diff --git a/astrbot/core/pipeline/process_stage/method/llm_request.py b/astrbot/core/pipeline/process_stage/method/llm_request.py index 7d7c4516f..3767cc59d 100644 --- a/astrbot/core/pipeline/process_stage/method/llm_request.py +++ b/astrbot/core/pipeline/process_stage/method/llm_request.py @@ -176,17 +176,14 @@ class LLMRequestSubStage(Stage): llm_response.tools_call_name, llm_response.tools_call_args ): try: - if func_tool_name.startswith("mcp:"): - _, mcp_server_name, mcp_func_name = func_tool_name.split( - ":" - ) + func_tool = req.func_tool.get_func(func_tool_name) + if func_tool.origin == "mcp": logger.info( - f"从mcp服务 {mcp_server_name} 调用工具函数:{mcp_func_name},参数:{func_tool_args}" + f"从 MCP 服务 {func_tool.mcp_server_name} 调用工具函数:{func_tool.name},参数:{func_tool_args}" ) - - client = req.func_tool.mcp_client_dict[mcp_server_name] + client = req.func_tool.mcp_client_dict[func_tool.mcp_server_name] res = await client.session.call_tool( - mcp_func_name, func_tool_args + func_tool.name, func_tool_args ) if res: # TODO content的类型可能包括list[TextContent | ImageContent | EmbeddedResource],这里只处理了TextContent。 @@ -194,11 +191,9 @@ class LLMRequestSubStage(Stage): event.set_result(res_event) yield else: - func_tool = req.func_tool.get_func(func_tool_name) logger.info( f"调用工具函数:{func_tool_name},参数:{func_tool_args}" ) - # 尝试调用工具函数 wrapper = self._call_handler( self.ctx, event, func_tool.handler, **func_tool_args @@ -208,7 +203,7 @@ class LLMRequestSubStage(Stage): function_calling_result[func_tool_name] = resp else: yield # 有生成器返回 - event.clear_result() # 清除上一个 handler 的结果 + event.clear_result() # 清除上一个 handler 的结果 except BaseException as e: logger.warning(traceback.format_exc()) function_calling_result[func_tool_name] = ( diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index 9379096e4..93d99de65 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -1,17 +1,27 @@ +from __future__ import annotations import json import textwrap import os +import asyncio +import mcp -from typing import Dict, List, Awaitable +from typing import Dict, List, Awaitable, Literal, Any from dataclasses import dataclass from typing import Optional from contextlib import AsyncExitStack -from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client from astrbot import logger -from anthropic import Anthropic +DEFAULT_MCP_CONFIG = {"mcpServers": {}} + +SUPPORTED_TYPES = [ + "string", + "number", + "object", + "array", + "boolean", +] # json schema 支持的数据类型 @dataclass @@ -23,49 +33,68 @@ class FuncTool: name: str parameters: Dict description: str - handler: Awaitable - handler_module_path: str = None # 必须要保留这个,handler 在初始化会被 functools.partial 包装,导致 handler 的 __module__ 为 functools + handler: Awaitable = None + """处理函数, 当 origin 为 mcp 时,这个为空""" + handler_module_path: str = None + """处理函数的模块路径,当 origin 为 mcp 时,这个为空 + 必须要保留这个字段, handler 在初始化会被 functools.partial 包装,导致 handler 的 __module__ 为 functools + """ active: bool = True """是否激活""" + origin: Literal["local", "mcp"] = "local" + """函数工具的来源, local 为本地函数工具, mcp 为 MCP 服务""" + + # MCP 相关字段 + mcp_server_name: str = None + """MCP 服务名称,当 origin 为 mcp 时有效""" + mcp_client: MCPClient = None + """MCP 客户端,当 origin 为 mcp 时有效""" + def __repr__(self): - return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description}), active={self.active})" + return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description}, active={self.active}, origin={self.origin})" - -SUPPORTED_TYPES = [ - "string", - "number", - "object", - "array", - "boolean", -] # json schema 支持的数据类型 + async def execute(self, **args) -> Any: + """执行函数调用""" + if self.origin == "local": + if not self.handler: + raise Exception(f"Local function {self.name} has no handler") + return await self.handler(**args) + elif self.origin == "mcp": + if not self.mcp_client or not self.mcp_client.session: + raise Exception(f"MCP client for {self.name} is not available") + # 使用name属性而不是额外的mcp_tool_name + if ":" in self.name: + # 如果名字是格式为 mcp:server:tool_name,提取实际的工具名 + actual_tool_name = self.name.split(":")[-1] + return await self.mcp_client.session.call_tool(actual_tool_name, args) + else: + return await self.mcp_client.session.call_tool(self.name, args) + else: + raise Exception(f"Unknown function origin: {self.origin}") class MCPClient: def __init__(self): # Initialize session and client objects - self.session: Optional[ClientSession] = None + self.session: Optional[mcp.ClientSession] = None self.exit_stack = AsyncExitStack() - self.anthropic = Anthropic() self.name = None self.active: bool = True + self.tools: List[mcp.Tool] = [] - async def connect_to_server(self, server_script_path: str): + async def connect_to_server(self, mcp_server_config: dict): """Connect to an MCP server Args: - server_script_path: Path to the server script (.py or .js) + mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server """ - is_python = server_script_path.endswith(".py") - is_js = server_script_path.endswith(".js") - if not (is_python or is_js): - raise ValueError("Server script must be a .py or .js file") - - command = "python" if is_python else "node" - server_params = StdioServerParameters( - command=command, args=[server_script_path], env=None + cfg = mcp_server_config.copy() + cfg.pop("active", None) + server_params = mcp.StdioServerParameters( + **cfg, ) stdio_transport = await self.exit_stack.enter_async_context( @@ -73,16 +102,31 @@ class MCPClient: ) self.stdio, self.write = stdio_transport self.session = await self.exit_stack.enter_async_context( - ClientSession(self.stdio, self.write) + mcp.ClientSession(self.stdio, self.write) ) await self.session.initialize() + async def list_tools_and_save(self) -> mcp.ListToolsResult: + """List all tools from the server and save them to self.tools""" + response = await self.session.list_tools() + logger.debug(f"MCP server {self.name} list tools response: {response}") + self.tools = response.tools + return response + + async def cleanup(self): + """Clean up resources""" + await self.exit_stack.aclose() + class FuncCall: def __init__(self) -> None: self.func_list: List[FuncTool] = [] + """内部加载的 func tools""" self.mcp_client_dict: Dict[str, MCPClient] = {} + """MCP 服务列表""" + self.mcp_service_queue = asyncio.Queue() + """用于外部控制 MCP 服务的启停""" def empty(self) -> bool: return len(self.func_list) == 0 @@ -137,55 +181,139 @@ class FuncCall: return f return None - async def init_mcp_client_list(self) -> None: + async def _init_mcp_clients(self) -> None: """从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下: ``` { "mcpServers": { - "example_cmp_server": { - "script_path": "path/to/cmp/server/script.py" + "weather": { + "command": "uv", + "args": [ + "--directory", + "/ABSOLUTE/PATH/TO/PARENT/FOLDER/weather", + "run", + "weather.py" + ] } - }, + } ... } ``` """ current_dir = os.path.dirname(os.path.abspath(__file__)) - project_root = os.path.abspath(os.path.join(current_dir, "../../..")) + data_dir = os.path.abspath(os.path.join(current_dir, "../../../data")) - mcp_json_file = os.path.join(project_root, "mcp_server.json") + mcp_json_file = os.path.join(data_dir, "mcp_server.json") if not os.path.exists(mcp_json_file): # 配置文件不存在错误处理 - logger.warning( - f"mcp server config file {mcp_json_file} not found. skip init mcp client list." - ) + with open(mcp_json_file, "w", encoding="utf-8") as f: + json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4) + logger.info(f"未找到 MCP 服务配置文件,已创建默认配置文件 {mcp_json_file}") return mcp_server_json_obj: Dict[str, Dict] = json.load( open(mcp_json_file, "r", encoding="utf-8") - ) + )["mcpServers"] + + for name in mcp_server_json_obj.keys(): + cfg = mcp_server_json_obj[name] + if cfg.get("active", True): + asyncio.create_task(self._init_mcp_client(name, cfg)) + + async def mcp_service_selector(self): + """为了避免在不同异步任务中控制 MCP 服务导致的报错,整个项目统一通过这个 Task 来控制 + + 使用 self.mcp_service_queue.put_nowait() 来控制 MCP 服务的启停,数据格式如下: + + {"type": "init"} 初始化所有MCP客户端 + + {"type": "init", "name": "mcp_server_name", "cfg": {...}} 初始化指定的MCP客户端 + + {"type": "terminate"} 终止所有MCP客户端 + + {"type": "terminate", "name": "mcp_server_name"} 终止指定的MCP客户端 + """ + while True: + data = await self.mcp_service_queue.get() + if data["type"] == "init": + if "name" in data: + asyncio.create_task(self._init_mcp_client(data["name"], data["cfg"])) + else: + await self._init_mcp_clients() + elif data["type"] == "terminate": + if "name" in data: + await self._terminate_mcp_client(data["name"]) + else: + for name in self.mcp_client_dict.keys(): + await self._terminate_mcp_client(name) + + async def _init_mcp_client(self, name: str, config: dict) -> None: + """初始化单个MCP客户端""" + try: + # 先清理之前的客户端,如果存在 + if name in self.mcp_client_dict: + await self._terminate_mcp_client(name) - for mcp_server_name, mcp_server_script_path in mcp_server_json_obj[ - "mcpServers" - ].items(): - if not os.path.exists(mcp_server_script_path["script_path"]): - logger.error( - f"MCP server import err: Server script {mcp_server_script_path['script_path']} not found." - ) - continue mcp_client = MCPClient() - mcp_client.name = mcp_server_name - await mcp_client.connect_to_server(mcp_server_script_path["script_path"]) - self.mcp_client_dict[mcp_server_name] = mcp_client - logger.info(f"添加 MCP 服务 {mcp_server_name}") - if len(self.mcp_client_dict) == 0: - logger.info("未启用任何 MCP 服务") + mcp_client.name = name + await mcp_client.connect_to_server(config) + tools_res = await mcp_client.list_tools_and_save() + tool_names = [tool.name for tool in tools_res.tools] + self.mcp_client_dict[name] = mcp_client - async def get_func_desc_openai_style(self) -> list: + # 移除该MCP服务之前的工具(如有) + self.func_list = [ + f + for f in self.func_list + if not (f.origin == "mcp" and f.mcp_server_name == name) + ] + + # 将 MCP 工具转换为 FuncTool 并添加到 func_list + for tool in mcp_client.tools: + func_tool = FuncTool( + name=tool.name, + parameters=tool.inputSchema, + description=tool.description, + origin="mcp", + mcp_server_name=name, + mcp_client=mcp_client, + ) + self.func_list.append(func_tool) + + logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}") + return True + except Exception as e: + logger.error(f"初始化 MCP 客户端 {name} 失败: {e}") + # 发生错误时确保客户端被清理 + if name in self.mcp_client_dict: + await self._terminate_mcp_client(name) + return False + + async def _terminate_mcp_client(self, name: str) -> None: + """关闭并清理MCP客户端""" + if name in self.mcp_client_dict: + try: + # 关闭MCP连接 + await self.mcp_client_dict[name].cleanup() + del self.mcp_client_dict[name] + except Exception as e: + logger.info( + f"清空 MCP 客户端资源 {name}: {e}。" + ) + # 移除关联的FuncTool + self.func_list = [ + f + for f in self.func_list + if not (f.origin == "mcp" and f.mcp_server_name == name) + ] + logger.info(f"已关闭 MCP 服务 {name}") + + def get_func_desc_openai_style(self) -> list: """ 获得 OpenAI API 风格的**已经激活**的工具描述 """ _l = [] + # 处理所有工具(包括本地和MCP工具) for f in self.func_list: if not f.active: continue @@ -199,20 +327,6 @@ class FuncCall: }, } ) - - for name, client in self.mcp_client_dict.items(): - responses = await client.session.list_tools() - for tool in responses.tools: - _l.append( - { - "type": "function", - "function": { - "name": f"mcp:{name}:{tool.name}", - "parameters": tool.inputSchema, - "description": tool.description, - }, - } - ) return _l def get_func_desc_anthropic_style(self) -> list: @@ -265,9 +379,9 @@ class FuncCall: continue _l.append( { - "name": f["name"], - "parameters": f["parameters"], - "description": f["description"], + "name": f.name, + "parameters": f.parameters, + "description": f.description, } ) func_definition = json.dumps(_l, ensure_ascii=False) @@ -317,14 +431,11 @@ class FuncCall: func_name = tool["name"] args = tool["args"] # 调用函数 - tool_callable = None - for func in self.func_list: - if func.name == func_name: - tool_callable = func.star_handler_metadata.handler - break - if not tool_callable: + func_tool = self.get_func(func_name) + if not func_tool: raise Exception(f"Request function {func_name} not found.") - ret = await tool_callable(**args) + + ret = await func_tool.execute(**args) if ret: tool_call_result.append(str(ret)) return tool_call_result, True @@ -334,3 +445,8 @@ class FuncCall: def __repr__(self): return str(self.func_list) + + async def terminate(self): + for name in self.mcp_client_dict.keys(): + await self._terminate_mcp_client(name) + logger.debug(f"清理 MCP 客户端 {name} 资源") diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index c1acf74c0..71b38682f 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -1,4 +1,5 @@ import traceback +import asyncio from astrbot.core.config.astrbot_config import AstrBotConfig from .provider import Provider, STTProvider, TTSProvider, Personality from .entites import ProviderType @@ -127,8 +128,9 @@ class ProviderManager: if self.tts_enabled and not self.curr_tts_provider_inst: logger.warning("未启用任何用于 文本转语音 的提供商适配器。") - # 初始化mcpclient连接 - await self.llm_tools.init_mcp_client_list() + # 初始化 MCP Client 连接 + asyncio.create_task(self.llm_tools.mcp_service_selector(), name="mcp-service-handler") + self.llm_tools.mcp_service_queue.put_nowait({"type": "init"}) async def load_provider(self, provider_config: dict): if not provider_config["enable"]: @@ -342,3 +344,5 @@ class ProviderManager: for provider_inst in self.provider_insts: if hasattr(provider_inst, "terminate"): await provider_inst.terminate() + # 清理 MCP Client 连接 + await self.llm_tools.mcp_service_queue.put({"type": "terminate"}) diff --git a/astrbot/core/provider/sources/llmtuner_source.py b/astrbot/core/provider/sources/llmtuner_source.py index adb5bf428..bfd9e03a5 100644 --- a/astrbot/core/provider/sources/llmtuner_source.py +++ b/astrbot/core/provider/sources/llmtuner_source.py @@ -85,7 +85,7 @@ class LLMTunerModelLoader(Provider): "system": system_prompt, } if func_tool: - tool_list = await func_tool.get_func_desc_openai_style() + tool_list = func_tool.get_func_desc_openai_style() if tool_list: conf["tools"] = tool_list diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 342c2febb..897fd4e7e 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -80,7 +80,7 @@ class ProviderOpenAIOfficial(Provider): async def _query(self, payloads: dict, tools: FuncCall) -> LLMResponse: if tools: - tool_list = await tools.get_func_desc_openai_style() + tool_list = tools.get_func_desc_openai_style() if tool_list: payloads["tools"] = tool_list @@ -124,11 +124,6 @@ class ProviderOpenAIOfficial(Provider): for tool in tools.func_list: if tool.name == tool_call.function.name: args = json.loads(tool_call.function.arguments) - if ( - tool_call.function.name.startswith("mcp:") - and tool_call.function.name.split(":")[1] in tools.mcp_client_dict - ): - args = json.loads(tool_call.function.arguments) args_ls.append(args) func_name_ls.append(tool_call.function.name) llm_response.role = "tool" diff --git a/astrbot/dashboard/routes/__init__.py b/astrbot/dashboard/routes/__init__.py index f4107bdc5..2e5461981 100644 --- a/astrbot/dashboard/routes/__init__.py +++ b/astrbot/dashboard/routes/__init__.py @@ -6,6 +6,7 @@ from .stat import StatRoute from .log import LogRoute from .static_file import StaticFileRoute from .chat import ChatRoute +from .tools import ToolsRoute # 导入新的ToolsRoute __all__ = [ @@ -17,4 +18,5 @@ __all__ = [ "LogRoute", "StaticFileRoute", "ChatRoute", + "ToolsRoute", # 添加新的ToolsRoute ] diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index 14af21bbc..dcfe50d38 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -146,6 +146,7 @@ class ConfigRoute(Route): "/config/provider/new": ("POST", self.post_new_provider), "/config/provider/update": ("POST", self.post_update_provider), "/config/provider/delete": ("POST", self.post_delete_provider), + "/config/llmtools": ("GET", self.get_llm_tools), } self.register_routes() @@ -278,6 +279,12 @@ class ConfigRoute(Route): return Response().error(str(e)).__dict__ return Response().ok(None, "删除成功,已经实时生效~").__dict__ + async def get_llm_tools(self): + """获取函数调用工具。包含了本地加载的以及 MCP 服务的工具""" + tool_mgr = self.core_lifecycle.provider_manager.llm_tools + tools = tool_mgr.get_func_desc_openai_style() + return Response().ok(tools).__dict__ + async def _get_astrbot_config(self): config = self.config diff --git a/astrbot/dashboard/routes/tools.py b/astrbot/dashboard/routes/tools.py new file mode 100644 index 000000000..8ba166784 --- /dev/null +++ b/astrbot/dashboard/routes/tools.py @@ -0,0 +1,250 @@ +import os +import json +import traceback +from .route import Route, Response, RouteContext +from quart import request +from astrbot.core.core_lifecycle import AstrBotCoreLifecycle +from astrbot.core import logger + +DEFAULT_MCP_CONFIG = {"mcpServers": {}} + + +class ToolsRoute(Route): + def __init__( + self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle + ) -> None: + super().__init__(context) + self.core_lifecycle = core_lifecycle + self.routes = { + "/tools/mcp/servers": ("GET", self.get_mcp_servers), + "/tools/mcp/add": ("POST", self.add_mcp_server), + "/tools/mcp/update": ("POST", self.update_mcp_server), + "/tools/mcp/delete": ("POST", self.delete_mcp_server), + } + self.register_routes() + self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools + + @property + def mcp_config_path(self): + current_dir = os.path.dirname(os.path.abspath(__file__)) + data_dir = os.path.abspath(os.path.join(current_dir, "../../../data")) + return os.path.join(data_dir, "mcp_server.json") + + def load_mcp_config(self): + if not os.path.exists(self.mcp_config_path): + # 配置文件不存在,创建默认配置 + os.makedirs(os.path.dirname(self.mcp_config_path), exist_ok=True) + with open(self.mcp_config_path, "w", encoding="utf-8") as f: + json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4) + return DEFAULT_MCP_CONFIG + + try: + with open(self.mcp_config_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + logger.error(f"加载 MCP 配置失败: {e}") + return DEFAULT_MCP_CONFIG + + def save_mcp_config(self, config): + try: + with open(self.mcp_config_path, "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=4) + return True + except Exception as e: + logger.error(f"保存 MCP 配置失败: {e}") + return False + + async def get_mcp_servers(self): + try: + config = self.load_mcp_config() + servers = [] + + # 获取所有服务器并添加它们的工具列表 + for name, server_config in config["mcpServers"].items(): + server_info = { + "name": name, + "active": server_config.get("active", True), + } + + # 复制所有配置字段 + for key, value in server_config.items(): + if key != "active": # active 已经处理 + server_info[key] = value + + # 如果MCP客户端已初始化,从客户端获取工具名称 + for ( + name_key, + mcp_client, + ) in self.tool_mgr.mcp_client_dict.items(): + if name_key == name: + server_info["tools"] = [tool.name for tool in mcp_client.tools] + break + else: + server_info["tools"] = [] + + servers.append(server_info) + + return Response().ok(servers).__dict__ + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"获取 MCP 服务器列表失败: {str(e)}").__dict__ + + async def add_mcp_server(self): + try: + server_data = await request.json + + name = server_data.get("name", "") + + # 检查必填字段 + if not name: + return Response().error("服务器名称不能为空").__dict__ + + # 移除特殊字段并检查配置是否有效 + has_valid_config = False + server_config = {"active": server_data.get("active", True)} + + # 复制所有配置字段 + for key, value in server_data.items(): + if key not in ["name", "active", "tools"]: # 排除特殊字段 + server_config[key] = value + has_valid_config = True + + if not has_valid_config: + return Response().error("必须提供有效的服务器配置").__dict__ + + config = self.load_mcp_config() + + if name in config["mcpServers"]: + return Response().error(f"服务器 {name} 已存在").__dict__ + + config["mcpServers"][name] = server_config + + if self.save_mcp_config(config): + # 动态初始化新MCP客户端 + self.tool_mgr.mcp_service_queue.put_nowait( + { + "type": "init", + "name": name, + "cfg": config["mcpServers"][name], + } + ) + return Response().ok(None, f"成功添加 MCP 服务器 {name}").__dict__ + else: + return Response().error("保存配置失败").__dict__ + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"添加 MCP 服务器失败: {str(e)}").__dict__ + + async def update_mcp_server(self): + try: + server_data = await request.json + + name = server_data.get("name", "") + + if not name: + return Response().error("服务器名称不能为空").__dict__ + + config = self.load_mcp_config() + + if name not in config["mcpServers"]: + return Response().error(f"服务器 {name} 不存在").__dict__ + + # 获取活动状态 + active = server_data.get("active", config["mcpServers"][name].get("active", True)) + + # 创建新的配置对象 + server_config = {"active": active} + + # 仅更新活动状态的特殊处理 + only_update_active = True + + # 复制所有配置字段 + for key, value in server_data.items(): + if key not in ["name", "active", "tools"]: # 排除特殊字段 + server_config[key] = value + only_update_active = False + + # 如果只更新活动状态,保留原始配置 + if only_update_active: + for key, value in config["mcpServers"][name].items(): + if key != "active": # 除了active之外的所有字段都保留 + server_config[key] = value + + config["mcpServers"][name] = server_config + + if self.save_mcp_config(config): + # 处理MCP客户端状态变化 + if active: + # 如果要激活服务器或者配置已更改 + if name in self.tool_mgr.mcp_client_dict or not only_update_active: + await self.tool_mgr.mcp_service_queue.put( + { + "type": "terminate", + "name": name, + } + ) + await self.tool_mgr.mcp_service_queue.put( + { + "type": "init", + "name": name, + "cfg": config["mcpServers"][name], + } + ) + else: + # 客户端不存在,初始化 + self.tool_mgr.mcp_service_queue.put_nowait( + { + "type": "init", + "name": name, + "cfg": config["mcpServers"][name], + } + ) + else: + # 如果要停用服务器 + if name in self.tool_mgr.mcp_client_dict: + self.tool_mgr.mcp_service_queue.put_nowait( + { + "type": "terminate", + "name": name, + } + ) + + return Response().ok(None, f"成功更新 MCP 服务器 {name}").__dict__ + else: + return Response().error("保存配置失败").__dict__ + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"更新 MCP 服务器失败: {str(e)}").__dict__ + + async def delete_mcp_server(self): + try: + server_data = await request.json + name = server_data.get("name", "") + + if not name: + return Response().error("服务器名称不能为空").__dict__ + + config = self.load_mcp_config() + + if name not in config["mcpServers"]: + return Response().error(f"服务器 {name} 不存在").__dict__ + + # 删除服务器配置 + del config["mcpServers"][name] + + if self.save_mcp_config(config): + # 关闭并删除MCP客户端 + if name in self.tool_mgr.mcp_client_dict: + self.tool_mgr.mcp_service_queue.put_nowait( + { + "type": "terminate", + "name": name, + } + ) + + return Response().ok(None, f"成功删除 MCP 服务器 {name}").__dict__ + else: + return Response().error("保存配置失败").__dict__ + except Exception as e: + logger.error(traceback.format_exc()) + return Response().error(f"删除 MCP 服务器失败: {str(e)}").__dict__ diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 45aac3cd6..9af11dd53 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -50,6 +50,7 @@ class AstrBotDashboard: self.sfr = StaticFileRoute(self.context) self.ar = AuthRoute(self.context) self.chat_route = ChatRoute(self.context, db, core_lifecycle) + self.tools_root = ToolsRoute(self.context, core_lifecycle) self.shutdown_event = shutdown_event diff --git a/dashboard/src/components/shared/ItemCardGrid.vue b/dashboard/src/components/shared/ItemCardGrid.vue new file mode 100644 index 000000000..a1ed1609e --- /dev/null +++ b/dashboard/src/components/shared/ItemCardGrid.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts index d2fbb556e..5325e0cfd 100644 --- a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts +++ b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts @@ -45,6 +45,11 @@ const sidebarItem: menu[] = [ icon: 'mdi-storefront', to: '/extension-marketplace' }, + { + title: '函数调用', + icon: 'mdi-function-variant', + to: '/tool-use' + }, { title: '聊天', icon: 'mdi-chat', diff --git a/dashboard/src/router/MainRoutes.ts b/dashboard/src/router/MainRoutes.ts index 5fb0abeea..9ce2402ca 100644 --- a/dashboard/src/router/MainRoutes.ts +++ b/dashboard/src/router/MainRoutes.ts @@ -31,6 +31,11 @@ const MainRoutes = { path: '/providers', component: () => import('@/views/ProviderPage.vue') }, + { + name: 'ToolUsePage', + path: '/tool-use', + component: () => import('@/views/ToolUsePage.vue') + }, { name: 'Configs', path: '/config', diff --git a/dashboard/src/views/PlatformPage.vue b/dashboard/src/views/PlatformPage.vue index ac3d24ad5..38fa08e68 100644 --- a/dashboard/src/views/PlatformPage.vue +++ b/dashboard/src/views/PlatformPage.vue @@ -1,316 +1,304 @@ - - \ No newline at end of file diff --git a/dashboard/src/views/ProviderPage.vue b/dashboard/src/views/ProviderPage.vue index c29247fb3..e2e1b796b 100644 --- a/dashboard/src/views/ProviderPage.vue +++ b/dashboard/src/views/ProviderPage.vue @@ -1,291 +1,328 @@ - - \ No newline at end of file diff --git a/dashboard/src/views/ToolUsePage.vue b/dashboard/src/views/ToolUsePage.vue new file mode 100644 index 000000000..f67496c43 --- /dev/null +++ b/dashboard/src/views/ToolUsePage.vue @@ -0,0 +1,631 @@ + + + + + \ No newline at end of file