From 55bad796d533720309e6e3e44af505f480c7db92 Mon Sep 17 00:00:00 2001 From: SilverLi Date: Fri, 7 Aug 2026 12:50:49 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(ai):=20chat()=20=E6=94=AF=E6=8C=81=20M?= =?UTF-8?q?CP=20=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=EF=BC=8CCLI=20?= =?UTF-8?q?=E5=8F=AF=E9=85=8D=E7=BD=AE=20MCP=20=E6=9C=8D=E5=8A=A1=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/scripts/check_runtime_imports.py | 1 + .gitignore | 2 + ncatbot/adapter/ai/adapter.py | 94 +++++- ncatbot/adapter/ai/api/__init__.py | 3 +- ncatbot/adapter/ai/api/bot_api.py | 105 +++++- ncatbot/adapter/ai/api/mcp.py | 254 +++++++++++++++ ncatbot/adapter/ai/config.py | 15 +- pyproject.toml | 1 + tests/README.md | 4 +- tests/unit/adapter/test_ai_adapter.py | 395 +++++++++++++++++++++++ uv.lock | 187 ++++++++++- 11 files changed, 1050 insertions(+), 11 deletions(-) create mode 100644 ncatbot/adapter/ai/api/mcp.py diff --git a/.agents/scripts/check_runtime_imports.py b/.agents/scripts/check_runtime_imports.py index db50de7de..e069788f6 100644 --- a/.agents/scripts/check_runtime_imports.py +++ b/.agents/scripts/check_runtime_imports.py @@ -45,6 +45,7 @@ OPTIONAL_DEPS: set[str] = { "bilibili_api", "litellm", + "mcp", "schedule", } diff --git a/.gitignore b/.gitignore index 2d78dcabe..26d367f27 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,8 @@ config.yaml # === Development === dev/ +.claude/ +CLAUDE.md # === WebUI frontend === ncatbot/webui/frontend/node_modules/ diff --git a/ncatbot/adapter/ai/adapter.py b/ncatbot/adapter/ai/adapter.py index ebc07ce55..30be274ca 100644 --- a/ncatbot/adapter/ai/adapter.py +++ b/ncatbot/adapter/ai/adapter.py @@ -22,6 +22,25 @@ LOG = get_log("AIAdapter") +def _parse_kv(text: str, sep: str = "=") -> Dict[str, str]: + """解析 ``KEY=VALUE`` 逗号分隔文本为字典。""" + result: Dict[str, str] = {} + for part in text.split(","): + part = part.strip() + if not part: + continue + key, _, value = part.partition(sep) + key = key.strip() + if key: + result[key] = value.strip() + return result + + +def _parse_headers(text: str) -> Dict[str, str]: + """解析 ``Key:Value`` 逗号分隔文本为请求头字典。""" + return _parse_kv(text, sep=":") + + class AIAdapter(BaseAdapter): """AI 适配器 — 通过 litellm 统一调用 100+ LLM 提供商 @@ -45,7 +64,10 @@ class AIAdapter(BaseAdapter): description = "AI 适配器(基于 litellm 的多模型统一接口)" supported_protocols: List[str] = ["litellm"] platform = "ai" - pip_dependencies: Dict[str, str] = {"litellm": ">=1.40.0"} + pip_dependencies: Dict[str, str] = { + "litellm": ">=1.40.0", + "mcp": ">=1.25.0,<2.0.0", + } @classmethod def cli_configure(cls) -> Dict[str, Any]: @@ -71,8 +93,78 @@ def cli_configure(cls) -> Dict[str, Any]: cfg["base_url"] = base_url if completion_model: cfg["completion_model"] = completion_model + + mcp_servers = cls._cli_configure_mcp() + if mcp_servers: + cfg["mcp_servers"] = mcp_servers return cfg + @classmethod + def _cli_configure_mcp(cls) -> Dict[str, Any]: + """交互式收集 MCP 服务器配置(LiteLLM 兼容格式)。""" + import click + + servers: Dict[str, Any] = {} + if not click.confirm( + "是否添加 MCP 服务器(让模型调用外部工具)?", default=False + ): + return servers + + while True: + click.echo( + click.style( + "\n ── MCP 服务器 ──\n" + " 传输类型: http (Streamable HTTP) / sse / stdio\n" + " http/sse 需 URL,stdio 需 command + args", + dim=True, + ) + ) + name = click.prompt("服务器名称(如 deepwiki)").strip() + if not name: + click.echo(click.style("服务器名称不能为空,已跳过", fg="yellow")) + continue + + transport = click.prompt( + "传输类型", type=click.Choice(["http", "sse", "stdio"]), default="http" + ) + + entry: Dict[str, Any] = {"transport": transport} + if transport == "stdio": + command = click.prompt("启动命令(如 npx / uvx)") + entry["command"] = command + args = click.prompt( + "参数(空格分隔,可直接回车跳过)", + default="", + show_default=False, + ) + if args: + entry["args"] = args.split() + env = click.prompt( + "环境变量(逗号分隔 KEY=VALUE,可回车跳过)", + default="", + show_default=False, + ) + parsed_env = _parse_kv(env) + if parsed_env: + entry["env"] = parsed_env + else: + url = click.prompt("MCP 服务器 URL") + entry["url"] = url + headers = click.prompt( + "请求头(逗号分隔 Key:Value,可回车跳过)", + default="", + show_default=False, + ) + parsed_headers = _parse_headers(headers) + if parsed_headers: + entry["headers"] = parsed_headers + + servers[name] = entry + if not click.confirm("继续添加 MCP 服务器?", default=False): + break + + return servers + def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self._ai_config = AIConfig(**self._raw_config) diff --git a/ncatbot/adapter/ai/api/__init__.py b/ncatbot/adapter/ai/api/__init__.py index 5b2e74bc8..ef6652464 100644 --- a/ncatbot/adapter/ai/api/__init__.py +++ b/ncatbot/adapter/ai/api/__init__.py @@ -1,5 +1,6 @@ """AI 平台 API 子模块""" from .bot_api import AIBotAPI +from .mcp import MCPSessionManager -__all__ = ["AIBotAPI"] +__all__ = ["AIBotAPI", "MCPSessionManager"] diff --git a/ncatbot/adapter/ai/api/bot_api.py b/ncatbot/adapter/ai/api/bot_api.py index 232fe66d4..582e1c73b 100644 --- a/ncatbot/adapter/ai/api/bot_api.py +++ b/ncatbot/adapter/ai/api/bot_api.py @@ -17,6 +17,7 @@ from ncatbot.utils import get_log from ..config import AIConfig +from .mcp import MCPSessionManager # 接受的输入类型:str / list[dict] / MessageArray / 单个 MessageSegment ChatInput = Union[str, List[dict], "MessageArray", "MessageSegment"] @@ -63,6 +64,8 @@ async def chat( temperature: Optional[float] = None, max_tokens: Optional[int] = None, nickname_map: Optional[Dict[str, str]] = None, + mcp_servers: Optional[Dict[str, dict]] = None, + max_tool_calls: int = 10, **kwargs: Any, ) -> Any: """Chat Completion @@ -83,6 +86,12 @@ async def chat( nickname_map: ``{user_id: 昵称}`` 映射,用于将 ``At`` 段转为可读文本。 缺省时 At 段渲染为 ``@{user_id}``。 + mcp_servers: + MCP 服务器配置字典(格式见 ``AIConfig.mcp_servers``)。 + 缺省使用配置中的 ``mcp_servers``;为空则不启用 MCP 工具。 + 启用后模型可调用 MCP 工具,工具名按 ``{server}_{tool}`` 命名空间区分。 + max_tool_calls: + 单轮对话中允许的最多工具调用轮数(默认 10),防止死循环。 Returns ------- @@ -106,14 +115,104 @@ async def chat( if resolved_max_tokens is not None: call_kwargs["max_tokens"] = resolved_max_tokens - return await self._call_with_fallback( + merged_mcp_servers = ( + mcp_servers if mcp_servers is not None else self._config.mcp_servers + ) + if not merged_mcp_servers: + return await self._call_with_fallback( + acompletion, + resolved_model, + self._config.completion_model, + messages=messages, + **call_kwargs, + ) + + return await self._chat_with_tools( acompletion, resolved_model, self._config.completion_model, - messages=messages, - **call_kwargs, + messages, + merged_mcp_servers, + max_tool_calls, + call_kwargs, ) + async def _chat_with_tools( + self, + acompletion: Any, + model: str, + default_model: str, + messages: List[dict], + mcp_servers: Dict[str, dict], + max_tool_calls: int, + call_kwargs: Dict[str, Any], + ) -> Any: + """带 MCP 工具调用循环的 Chat Completion。 + + 模型请求工具 → 执行 MCP 工具 → 回传结果 → 继续对话, + 直到模型不再请求工具或达到 ``max_tool_calls`` 上限。 + """ + async with MCPSessionManager(mcp_servers) as mcp: + tools = await mcp.load_tools() + if not tools: + LOG.warning("MCP 服务器未加载到任何工具,按普通对话处理(无 tools)") + return await self._call_with_fallback( + acompletion, + model, + default_model, + messages=messages, + **call_kwargs, + ) + + msgs = list(messages) + resp: Any = None + for _ in range(max_tool_calls): + resp = await self._call_with_fallback( + acompletion, + model, + default_model, + messages=msgs, + tools=tools, + **call_kwargs, + ) + message = resp.choices[0].message + tool_calls = getattr(message, "tool_calls", None) + if not tool_calls: + return resp + + msgs.append(self._assistant_tool_message(message, tool_calls)) + for tool_call in tool_calls: + try: + result_text = await mcp.call_openai_tool(tool_call) + except Exception as exc: # noqa: BLE001 + LOG.error("MCP 工具调用失败: %s", exc) + result_text = f"[MCP 工具调用失败: {exc}]" + msgs.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": result_text, + } + ) + + LOG.warning("MCP 工具调用达到上限 %d 轮,返回最后一次响应", max_tool_calls) + return resp + + @staticmethod + def _assistant_tool_message(message: Any, tool_calls: List[Any]) -> dict: + """构造带 tool_calls 的 assistant 消息,供下一轮请求使用。""" + dumped = [] + for tc in tool_calls: + if hasattr(tc, "model_dump"): + dumped.append(tc.model_dump()) + else: + dumped.append(dict(tc)) + return { + "role": "assistant", + "content": getattr(message, "content", None), + "tool_calls": dumped, + } + async def embeddings( self, input_text: Union[str, List[str]], diff --git a/ncatbot/adapter/ai/api/mcp.py b/ncatbot/adapter/ai/api/mcp.py new file mode 100644 index 000000000..87f032253 --- /dev/null +++ b/ncatbot/adapter/ai/api/mcp.py @@ -0,0 +1,254 @@ +"""MCP (Model Context Protocol) 客户端 — 管理 MCP 服务器会话 + +支持 stdio / http (Streamable HTTP) / sse 三种传输,配置格式与 LiteLLM 兼容:: + + mcp_servers: + server_name: + transport: "http" | "sse" | "stdio" # 缺省自动判断 + url: "https://mcp.example.com/mcp" # http / sse + headers: {Authorization: "Bearer ..."} # http / sse + command: "npx" # stdio + args: ["-y", "@mcp/server"] # stdio + env: {TOKEN: "..."} # stdio + +多个服务器加载的工具按 ``{server_name}_{tool_name}`` 命名空间区分, +避免同名工具冲突,与 LiteLLM MCP Gateway 的行为一致。 +""" + +from __future__ import annotations + +import contextlib +import json +from typing import Any, AsyncContextManager, Dict, List, Optional, Tuple + +from ncatbot.utils import get_log + +LOG = get_log("AIMCP") + + +class MCPSessionManager: + """管理一组 MCP 服务器会话 + + 负责连接的创建与关闭;跨服务器加载工具、处理工具调用。 + 支持作为异步上下文管理器使用:: + + async with MCPSessionManager(servers) as mcp: + tools = await mcp.load_tools() + text = await mcp.call_tool("server_get_weather", {"city": "北京"}) + """ + + def __init__(self, servers: Optional[Dict[str, Dict[str, Any]]] = None) -> None: + self._servers = servers or {} + self._stack = contextlib.AsyncExitStack() + # server_name -> 已初始化的 ClientSession + self._sessions: Dict[str, Any] = {} + # 命名空间化工具名 -> (server_name, 原始工具名) + self._tool_map: Dict[str, Tuple[str, str]] = {} + + async def __aenter__(self) -> "MCPSessionManager": + await self.connect() + return self + + async def __aexit__(self, *exc: Any) -> None: + await self.close() + + @property + def connected(self) -> bool: + return bool(self._sessions) + + async def connect(self) -> None: + """连接所有配置的 MCP 服务器并初始化会话。 + + 单个服务器失败不会阻断其他服务器,记录错误后继续。 + """ + for name, cfg in self._servers.items(): + try: + session = await self._stack.enter_async_context( + self._build_session_cm(name, cfg) + ) + await session.initialize() + self._sessions[name] = session + LOG.info("MCP 服务器 %s 已连接", name) + except Exception as exc: # noqa: BLE001 + LOG.error("MCP 服务器 %s 连接失败: %s", name, exc) + + async def close(self) -> None: + """关闭所有 MCP 会话与底层传输。""" + self._sessions.clear() + self._tool_map.clear() + await self._stack.aclose() + LOG.info("MCP 会话已全部关闭") + + # ---- 工具加载与调用 ---- + + async def load_tools(self, format: str = "openai") -> List[Dict[str, Any]]: + """加载所有已连接服务器提供的工具。 + + Parameters + ---------- + format: + 返回格式,仅支持 ``"openai"``(OpenAI function calling 格式)。 + + Returns + ------- + 工具列表,工具名为 ``{server_name}_{tool_name}``。 + """ + from litellm.experimental_mcp_client.tools import ( + load_mcp_tools, + transform_mcp_tool_to_openai_tool, + ) + + tools: List[Dict[str, Any]] = [] + for server_name, session in self._sessions.items(): + try: + mcp_tools = await load_mcp_tools(session, format="mcp") + except Exception as exc: # noqa: BLE001 + LOG.warning("MCP 服务器 %s 加载工具失败: %s", server_name, exc) + continue + for tool in mcp_tools: + ns_name = f"{server_name}_{tool.name}" + self._tool_map[ns_name] = (server_name, tool.name) + openai_tool = transform_mcp_tool_to_openai_tool(tool) + openai_tool["function"]["name"] = ns_name + tools.append(openai_tool) + return tools + + async def call_tool(self, name: str, arguments: Dict[str, Any]) -> str: + """调用一个已加载的 MCP 工具,返回文本结果。 + + Parameters + ---------- + name: + 命名空间化工具名(``{server_name}_{tool_name}``)。 + arguments: + 工具参数。 + """ + server_name, tool_name = self._resolve_tool(name) + session = self._sessions[server_name] + result = await session.call_tool(tool_name, arguments=arguments) + return self._result_to_text(result) + + async def call_openai_tool(self, openai_tool: Any) -> str: + """执行 OpenAI 工具调用对象(``ChatCompletionMessageToolCall``)。""" + function = self._get_function(openai_tool) + name = function["name"] + arguments = function.get("arguments", {}) + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except json.JSONDecodeError: + arguments = {} + if not isinstance(arguments, dict): + arguments = {} + return await self.call_tool(name, arguments) + + # ---- 内部辅助 ---- + + def _build_session_cm(self, name: str, cfg: Dict[str, Any]) -> AsyncContextManager: + """根据服务器配置构造一个连接 + 会话的异步上下文管理器。""" + transport = self._resolve_transport(cfg) + + @contextlib.asynccontextmanager + async def _stdio(): + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + + params = StdioServerParameters( + command=cfg["command"], + args=list(cfg.get("args") or []), + env=cfg.get("env"), + ) + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + yield session + + @contextlib.asynccontextmanager + async def _http(): + from mcp import ClientSession + from mcp.client.streamable_http import streamablehttp_client + + # 部分版本返回三元组 (read, write, get_session_id) + async with streamablehttp_client( + cfg["url"], headers=cfg.get("headers") + ) as streams: + read, write = streams[0], streams[1] + async with ClientSession(read, write) as session: + yield session + + @contextlib.asynccontextmanager + async def _sse(): + from mcp import ClientSession + from mcp.client.sse import sse_client + + async with sse_client(cfg["url"], headers=cfg.get("headers")) as ( + read, + write, + ): + async with ClientSession(read, write) as session: + yield session + + if transport == "stdio": + if not cfg.get("command"): + raise ValueError(f"MCP 服务器 {name} 的 stdio 传输缺少 command") + return _stdio() + if transport == "http": + if not cfg.get("url"): + raise ValueError(f"MCP 服务器 {name} 的 http 传输缺少 url") + return _http() + if transport == "sse": + if not cfg.get("url"): + raise ValueError(f"MCP 服务器 {name} 的 sse 传输缺少 url") + return _sse() + raise ValueError(f"MCP 服务器 {name} 不支持的传输类型: {transport}") + + @staticmethod + def _resolve_transport(cfg: Dict[str, Any]) -> str: + """解析传输类型,缺省时按配置内容自动判断。 + + 优先取 ``transport`` 字段;否则 ``command`` 存在走 stdio, + 否则有 ``url`` 走 http(Streamable HTTP,MCP 现行标准)。 + """ + transport = cfg.get("transport") + if transport: + return transport.lower() + if cfg.get("command"): + return "stdio" + if cfg.get("url"): + return "http" + return "stdio" + + def _resolve_tool(self, name: str) -> Tuple[str, str]: + """根据命名空间化工具名定位服务器与原始工具名。""" + if name in self._tool_map: + return self._tool_map[name] + # 兜底:按第一个 "_" 拆分出服务器名 + server_name, _, tool_name = name.partition("_") + if server_name not in self._sessions: + raise KeyError(f"MCP 工具 {name} 未加载或服务器未连接") + return server_name, tool_name or name + + @staticmethod + def _get_function(openai_tool: Any) -> Dict[str, Any]: + """兼容从对象或 dict 中取出 function 字段。""" + if hasattr(openai_tool, "function"): + function = openai_tool.function + else: + function = openai_tool["function"] + if hasattr(function, "model_dump"): + return function.model_dump() + return dict(function) + + @staticmethod + def _result_to_text(result: Any) -> str: + """将 MCP ``CallToolResult`` 的 content 块转为纯文本。""" + parts: List[str] = [] + for block in result.content: + if getattr(block, "type", "") == "text": + parts.append(block.text) + else: + # 图片等其他内容块:以 repr 兜底保留信息 + parts.append(repr(block)) + text = "\n".join(parts) + if getattr(result, "isError", False): + text = f"[MCP 工具调用错误]\n{text}" + return text diff --git a/ncatbot/adapter/ai/config.py b/ncatbot/adapter/ai/config.py index c71717427..82a75728c 100644 --- a/ncatbot/adapter/ai/config.py +++ b/ncatbot/adapter/ai/config.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Optional +from typing import Any, Dict, Optional from pydantic import BaseModel @@ -27,6 +27,18 @@ class AIConfig(BaseModel): 请求超时(秒)。 max_tokens: 默认最大 token 数,为 ``None`` 时由模型自行决定。 + mcp_servers: + MCP 服务器配置(配置格式与 LiteLLM 兼容),``chat()`` 时自动加载 + 其工具供模型调用。格式:: + + mcp_servers: + server_name: + transport: "http" | "sse" | "stdio" # 缺省自动判断 + url: "https://mcp.example.com/mcp" # http / sse + headers: {Authorization: "Bearer ..."} # http / sse + command: "npx" # stdio + args: ["-y", "@mcp/server"] # stdio + env: {TOKEN: "..."} # stdio """ api_key: str = "" @@ -37,3 +49,4 @@ class AIConfig(BaseModel): asr_model: str = "" timeout: float = 120.0 max_tokens: Optional[int] = None + mcp_servers: Dict[str, Any] = {} diff --git a/pyproject.toml b/pyproject.toml index fe8f1b27d..b84cba110 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ test = [ "pytest-cov>=4.0", "pytest-html>=4.0", "litellm>=1.83.0", + "mcp>=1.25.0,<2.0.0", ] # 开发依赖。使用 uv sync --extra dev 安装(包含测试依赖) dev = [ diff --git a/tests/README.md b/tests/README.md index 27580e6a5..868f254f2 100644 --- a/tests/README.md +++ b/tests/README.md @@ -14,7 +14,7 @@ tests/ │ ├── service/ # 服务管理 + RBAC + 调度 (SM-01 ~ SM-08, SC-01 ~ SC-12, TS-01 ~ TS-06) │ ├── plugin/ # 插件 Mixin + 导入去重 + Loader (M-01 ~ M-41, ID-01 ~ ID-02, LD-01 ~ LD-05) │ ├── adapter/ # 适配器解析 + 注册表 + 真实数据 + 事件日志格式 (P-01 ~ P-07, RF-01 ~ RF-08, AR-01 ~ AR-05, SL-01 ~ SL-04, GM-01 ~ GM-05, BL-01 ~ BL-25, GH-01 ~ GH-11, LK-01 ~ LK-09, LKP-01 ~ LKP-10, ELS-01 ~ ELS-17) -│ ├── config/ # 配置迁移 + 安全 + 分层 + 事件日志格式 (CF-01 ~ CF-05, CS-01 ~ CS-05, CE-01 ~ CE-05, BQ-01 ~ BQ-11, AI-03 ~ AI-20, ELF-01 ~ ELF-06) +│ ├── config/ # 配置迁移 + 安全 + 分层 + 事件日志格式 (CF-01 ~ CF-05, CS-01 ~ CS-05, CE-01 ~ CE-05, BQ-01 ~ BQ-11, AI-03 ~ AI-30, ELF-01 ~ ELF-06) │ ├── cli/ # CLI 冒烟 (CX-01 ~ CX-22) │ └── webui/ # WebUI 单元测试 (WUI-01 ~ WUI-14) ├── integration/ # 集成测试 (I-01 ~ I-21, WUI-I-01 ~ WUI-I-04) @@ -105,7 +105,7 @@ python tests/e2e/napcat/run.py | LKE | 飞书事件实体 | LKE-01 ~ LKE-08 | | LKP | 飞书 PostBuilder & MessageArray 转换 | LKP-01 ~ LKP-10 | | BQ | Bilibili 查询 API (parse_bili_id / audio / subtitle) | BQ-01 ~ BQ-11 | -| AI | AI 适配器 (chat / image / ASR) | AI-03 ~ AI-20 | +| AI | AI 适配器 (chat / image / ASR / MCP) | AI-03 ~ AI-30 | | ELF | Event Log Format Config | ELF-01 ~ ELF-06 | | ELS | Event Log Summary | ELS-01 ~ ELS-17 | | WUI | WebUI 单元测试 | WUI-01 ~ WUI-14 | diff --git a/tests/unit/adapter/test_ai_adapter.py b/tests/unit/adapter/test_ai_adapter.py index a446f44b0..c0330d55f 100644 --- a/tests/unit/adapter/test_ai_adapter.py +++ b/tests/unit/adapter/test_ai_adapter.py @@ -20,6 +20,14 @@ AI-18: transcription() 模型不存在时回退到默认 asr_model AI-19: transcription_text() 返回文本字符串 AI-20: transcription() 透传 language/prompt/response_format/temperature + AI-21: chat() 带 MCP 服务器时加载工具并传给 acompletion + AI-22: chat() 无 mcp_servers 时不传 tools + AI-23: chat() MCP 工具调用循环(请求工具 → 执行 → 回传 → 完成) + AI-24: chat() MCP 工具调用达到 max_tool_calls 上限 + AI-25: MCP 传输类型自动判断 + AI-26: MCP 工具加载与命名空间化({server}_{tool}) + AI-27: MCP 工具调用返回文本结果 + AI-28: MCP 单个服务器连接失败不影响其他服务器 """ import asyncio @@ -29,6 +37,7 @@ from ncatbot.adapter.ai.config import AIConfig from ncatbot.adapter.ai.api.bot_api import AIBotAPI +from ncatbot.adapter.ai.api.mcp import MCPSessionManager from ncatbot.adapter.ai.adapter import AIAdapter from ncatbot.types import Image @@ -570,3 +579,389 @@ async def test_transcription_kwargs_passthrough(): assert call_kwargs["prompt"] == "这是一段中文语音" assert call_kwargs["response_format"] == "json" assert call_kwargs["temperature"] == 0.2 + + +# ---- MCP 支持(AI-21 ~ AI-30) ---- + + +class FakeMCPManager: + """MCPSessionManager 的替身,供 chat() 工具调用测试使用。""" + + def __init__(self, tools=None): + self.tools = tools or [ + { + "type": "function", + "function": { + "name": "weather_get_weather", + "description": "查询天气", + "parameters": {"type": "object", "properties": {}}, + }, + } + ] + self.called_tools: list = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return None + + async def load_tools(self): + return self.tools + + async def call_openai_tool(self, tool_call): + self.called_tools.append(tool_call) + return "北京天气晴朗" + + +def _make_response(content=None, tool_calls=None): + """构造 litellm ModelResponse 风格的 mock 响应。""" + message = MagicMock() + message.content = content + message.tool_calls = tool_calls + choice = MagicMock() + choice.message = message + resp = MagicMock() + resp.choices = [choice] + return resp + + +# ---- AI-21 ---- + + +@pytest.mark.asyncio +async def test_chat_mcp_loads_tools_param(): + """AI-21: chat() 传 mcp_servers 时加载工具并传给 acompletion""" + cfg = AIConfig(completion_model="gpt-4") + api = AIBotAPI(cfg) + + resp = _make_response(content="回答", tool_calls=None) + with ( + patch( + "ncatbot.adapter.ai.api.bot_api.MCPSessionManager", + return_value=FakeMCPManager(), + ), + patch("litellm.acompletion", AsyncMock(return_value=resp)) as mock_fn, + ): + await api.chat("北京天气如何?", mcp_servers={"weather": {"url": "http://x"}}) + + call_kwargs = mock_fn.call_args.kwargs + assert call_kwargs.get("tools") == FakeMCPManager().tools + assert call_kwargs["tools"][0]["function"]["name"] == "weather_get_weather" + + +@pytest.mark.asyncio +async def test_chat_mcp_uses_config_default(): + """AI-21: chat() 未显式传 mcp_servers 时使用 config 默认值""" + cfg = AIConfig( + completion_model="gpt-4", + mcp_servers={"weather": {"url": "http://x"}}, + ) + api = AIBotAPI(cfg) + + resp = _make_response(content="回答", tool_calls=None) + with ( + patch( + "ncatbot.adapter.ai.api.bot_api.MCPSessionManager", + return_value=FakeMCPManager(), + ), + patch("litellm.acompletion", AsyncMock(return_value=resp)) as mock_fn, + ): + await api.chat("北京天气如何?") + + assert "tools" in mock_fn.call_args.kwargs + + +# ---- AI-22 ---- + + +@pytest.mark.asyncio +async def test_chat_no_mcp_no_tools(): + """AI-22: 未配置 MCP 时不传 tools 参数(回归保护)""" + cfg = AIConfig(completion_model="gpt-4") + api = AIBotAPI(cfg) + + resp = _make_response(content="回答", tool_calls=None) + with patch("litellm.acompletion", AsyncMock(return_value=resp)) as mock_fn: + await api.chat("hello") + + assert "tools" not in mock_fn.call_args.kwargs + + +# ---- AI-23 ---- + + +@pytest.mark.asyncio +async def test_chat_mcp_tool_call_loop(): + """AI-23: chat() 模型请求工具 → 执行 MCP 工具 → 回传结果 → 完成""" + from litellm.types.utils import ChatCompletionMessageToolCall + + cfg = AIConfig(completion_model="gpt-4") + api = AIBotAPI(cfg) + + first = _make_response( + content=None, + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_1", + type="function", + function={ + "name": "weather_get_weather", + "arguments": '{"city": "北京"}', + }, + ) + ], + ) + second = _make_response(content="北京今天天气晴朗", tool_calls=None) + + manager = FakeMCPManager() + with ( + patch("ncatbot.adapter.ai.api.bot_api.MCPSessionManager", return_value=manager), + patch("litellm.acompletion", AsyncMock(side_effect=[first, second])) as mock_fn, + ): + result = await api.chat( + "北京天气如何?", mcp_servers={"weather": {"url": "http://x"}} + ) + + assert result is second + assert mock_fn.await_count == 2 + assert len(manager.called_tools) == 1 + assert manager.called_tools[0].id == "call_1" + + # 第二次调用应包含 assistant 工具请求 + tool 结果消息 + msgs = mock_fn.await_args.kwargs["messages"] + assert msgs[-2]["role"] == "assistant" + assert msgs[-2]["tool_calls"][0]["id"] == "call_1" + assert msgs[-1] == { + "role": "tool", + "tool_call_id": "call_1", + "content": "北京天气晴朗", + } + + +# ---- AI-24 ---- + + +@pytest.mark.asyncio +async def test_chat_mcp_max_tool_calls(): + """AI-24: chat() 工具调用达到 max_tool_calls 上限时返回最后一次响应""" + from litellm.types.utils import ChatCompletionMessageToolCall + + cfg = AIConfig(completion_model="gpt-4") + api = AIBotAPI(cfg) + + always_tools = _make_response( + content=None, + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_1", + type="function", + function={"name": "weather_get_weather", "arguments": "{}"}, + ) + ], + ) + + manager = FakeMCPManager() + with ( + patch("ncatbot.adapter.ai.api.bot_api.MCPSessionManager", return_value=manager), + patch("litellm.acompletion", AsyncMock(return_value=always_tools)) as mock_fn, + ): + result = await api.chat( + "hi", mcp_servers={"weather": {"url": "http://x"}}, max_tool_calls=3 + ) + + assert mock_fn.await_count == 3 + assert result is always_tools + + +# ---- AI-25 ---- + + +def test_mcp_transport_resolution(): + """AI-25: MCP 传输类型缺省自动判断""" + assert MCPSessionManager._resolve_transport({"url": "https://x/mcp"}) == "http" + assert MCPSessionManager._resolve_transport({"command": "npx"}) == "stdio" + assert ( + MCPSessionManager._resolve_transport({"transport": "sse", "url": "https://x"}) + == "sse" + ) + assert ( + MCPSessionManager._resolve_transport({"transport": "http", "url": "https://x"}) + == "http" + ) + assert MCPSessionManager._resolve_transport({}) == "stdio" + + +# ---- AI-26 ---- + + +@pytest.mark.asyncio +async def test_mcp_load_tools_namespace(): + """AI-26: MCP 工具加载并按 {server}_{tool} 命名空间化""" + from mcp.types import ListToolsResult, Tool + + class FakeSession: + def __init__(self, tools): + self._tools = tools + + async def list_tools(self): + return ListToolsResult(tools=self._tools) + + tool = Tool( + name="get_weather", + description="查询天气", + inputSchema={"type": "object", "properties": {}}, + ) + mgr = MCPSessionManager({}) + mgr._sessions = {"weather": FakeSession([tool])} + + tools = await mgr.load_tools() + + assert len(tools) == 1 + assert tools[0]["function"]["name"] == "weather_get_weather" + assert mgr._tool_map["weather_get_weather"] == ("weather", "get_weather") + + +# ---- AI-27 ---- + + +@pytest.mark.asyncio +async def test_mcp_call_tool_returns_text(): + """AI-27: MCP 工具调用返回文本结果""" + from mcp.types import CallToolResult, TextContent + + class FakeSession: + def __init__(self, result): + self._result = result + self.called = None + + async def call_tool(self, name, arguments): + self.called = (name, arguments) + return self._result + + result = CallToolResult( + content=[TextContent(type="text", text="sunny")], isError=False + ) + session = FakeSession(result) + mgr = MCPSessionManager({}) + mgr._sessions = {"weather": session} + mgr._tool_map = {"weather_get_weather": ("weather", "get_weather")} + + text = await mgr.call_tool("weather_get_weather", {"city": "Beijing"}) + + assert text == "sunny" + assert session.called == ("get_weather", {"city": "Beijing"}) + + +@pytest.mark.asyncio +async def test_mcp_call_tool_error_marked(): + """AI-27: MCP 工具 isError 时返回错误标注""" + from mcp.types import CallToolResult, TextContent + + class FakeSession: + async def call_tool(self, name, arguments): + return CallToolResult( + content=[TextContent(type="text", text="no permission")], + isError=True, + ) + + mgr = MCPSessionManager({}) + mgr._sessions = {"srv": FakeSession()} + mgr._tool_map = {"srv_tool": ("srv", "tool")} + + text = await mgr.call_tool("srv_tool", {}) + + assert text == "[MCP 工具调用错误]\nno permission" + + +# ---- AI-28 ---- + + +@pytest.mark.asyncio +async def test_mcp_connect_tolerates_single_failure(): + """AI-28: 单个 MCP 服务器连接失败不影响其他服务器""" + import contextlib + + mgr = MCPSessionManager({"ok": {}, "bad": {"fail": True}}) + + def _fake_build(name, cfg): + @contextlib.asynccontextmanager + async def _cm(): + session = MagicMock() + session.initialize = AsyncMock() + if cfg.get("fail"): + session.initialize.side_effect = RuntimeError("connect failed") + yield session + + return _cm() + + with patch.object(mgr, "_build_session_cm", side_effect=_fake_build): + await mgr.connect() + + assert "ok" in mgr._sessions + assert "bad" not in mgr._sessions + assert mgr.connected is True + await mgr.close() + + +# ---- AI-29 ---- + + +def test_cli_configure_mcp_http(monkeypatch): + """AI-29: cli_configure() 交互收集 http MCP 服务器""" + responses = iter( + [ + "yes", # 是否添加 MCP + "deepwiki", # 服务器名称 + "http", # 传输类型 + "https://mcp.deepwiki.com/mcp", # url + "Authorization:Bearer abc", # headers + "no", # 继续添加? + ] + ) + monkeypatch.setattr( + "click.confirm", + lambda msg, *a, **k: next(responses).lower().startswith("y"), + ) + monkeypatch.setattr("click.prompt", lambda msg, *a, **k: next(responses)) + + servers = AIAdapter._cli_configure_mcp() + + assert servers == { + "deepwiki": { + "transport": "http", + "url": "https://mcp.deepwiki.com/mcp", + "headers": {"Authorization": "Bearer abc"}, + } + } + + +def test_cli_configure_mcp_stdio(monkeypatch): + """AI-29: cli_configure() 交互收集 stdio MCP 服务器""" + responses = iter( + [ + "yes", # 是否添加 MCP + "mcp", # 服务器名称 + "stdio", # 传输类型 + "npx", # command + "-y @mcp/server", # args + "TOKEN=abc", # env + "no", # 继续添加? + ] + ) + monkeypatch.setattr( + "click.confirm", + lambda msg, *a, **k: next(responses).lower().startswith("y"), + ) + monkeypatch.setattr("click.prompt", lambda msg, *a, **k: next(responses)) + + servers = AIAdapter._cli_configure_mcp() + + assert servers == { + "mcp": { + "transport": "stdio", + "command": "npx", + "args": ["-y", "@mcp/server"], + "env": {"TOKEN": "abc"}, + } + } diff --git a/uv.lock b/uv.lock index 9d764a292..fc4522d41 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,12 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform != 'win32'", +] [manifest] constraints = [ @@ -198,24 +204,52 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] @@ -414,33 +448,48 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, ] [[package]] @@ -687,6 +736,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "huggingface-hub" version = "1.7.2" @@ -1082,6 +1140,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mcp" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1243,7 +1326,7 @@ wheels = [ [[package]] name = "ncatbot5" -version = "5.5.5" +version = "5.5.6" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -1265,6 +1348,7 @@ dependencies = [ dev = [ { name = "build" }, { name = "litellm" }, + { name = "mcp" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, @@ -1278,6 +1362,7 @@ dev = [ ] test = [ { name = "litellm" }, + { name = "mcp" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -1291,6 +1376,7 @@ requires-dist = [ { name = "click", specifier = ">=8.1" }, { name = "httpx", specifier = ">=0.27" }, { name = "litellm", marker = "extra == 'test'", specifier = ">=1.83.0" }, + { name = "mcp", marker = "extra == 'test'", specifier = ">=1.25.0,<2.0.0" }, { name = "mypy", marker = "extra == 'dev'" }, { name = "ncatbot5", extras = ["test"], marker = "extra == 'dev'" }, { name = "packaging" }, @@ -1608,6 +1694,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -1617,6 +1717,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyproject-api" version = "1.10.0" @@ -1729,6 +1843,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -2081,8 +2223,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "sys_platform != 'win32'" }, + { name = "jeepney", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -2107,6 +2249,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/00/b42a44342a054d58cb1115d7c8aa9cb4290dd9442f9c1b91a4b8173dba22/sse_starlette-3.4.8.tar.gz", hash = "sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c", size = 32548, upload-time = "2026-08-05T11:19:49.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/3a/764912c58293d95b6dcdf4cc255f9d10de310580ced547b082eb9d72018c/sse_starlette-3.4.8-py3-none-any.whl", hash = "sha256:6e82314c786709a3cd9520f2285cf9fff90e181e598e8a357b0cf80f66afba0d", size = 16516, upload-time = "2026-08-05T11:19:48.748Z" }, +] + +[[package]] +name = "starlette" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/3c/76d2fd1f1357ed0f0108d8a5aa233dcf16e2946a8559c84912fe08e01ac7/starlette-1.4.1.tar.gz", hash = "sha256:b7332de6e9375593a29ba9eee1e6ecfeb3eb2043e2e19a13b4b71da73ff35540", size = 2709041, upload-time = "2026-08-05T15:17:26.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/0a/67e95f21498de41433babf7b1db0eeab449eb58872dfb831b27747a70fd0/starlette-1.4.1-py3-none-any.whl", hash = "sha256:7d078e0fbefae0d2cecfb80a799d6fb84b1c0c6acd4f14ac79d17d0e7ec27f19", size = 74019, upload-time = "2026-08-05T15:17:24.357Z" }, +] + [[package]] name = "tiktoken" version = "0.12.0" @@ -2337,6 +2505,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/92/9ca420deb5a7b6716d8746e1b05eb2c35a305ff3b4aa57061919087d82dd/uv-0.10.12-py3-none-win_arm64.whl", hash = "sha256:6727e3a0208059cd4d621684e580d5e254322dacbd806e0d218360abd0d48a68", size = 22544602, upload-time = "2026-03-19T21:51:22.678Z" }, ] +[[package]] +name = "uvicorn" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" }, +] + [[package]] name = "virtualenv" version = "21.2.0" From fa95a85e322948bebe180daf58f1e32062fcf7cd Mon Sep 17 00:00:00 2001 From: SilverLi Date: Fri, 7 Aug 2026 14:23:06 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs(ai):=20=E8=A1=A5=E5=85=85=20AI=20?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=20MCP=20=E5=B7=A5=E5=85=B7=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs b/docs index 9e3b5b03f..0c8169a26 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 9e3b5b03f189b0d9936ce728389895adf19d6f72 +Subproject commit 0c8169a263a922b85fff80aa33a40d84afa974c3 From 22583af065020350007ac03e58cca4da3827f756 Mon Sep 17 00:00:00 2001 From: SilverLi Date: Fri, 7 Aug 2026 14:54:21 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(ai):=20=E6=8E=A5=E5=8F=A3=E8=A1=A5?= =?UTF-8?q?=E5=85=85=20MCP=20=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs | 2 +- ncatbot/adapter/ai/api/bot_api.py | 4 ++++ ncatbot/api/ai/interface.py | 9 +++++++++ tests/README.md | 4 ++-- tests/unit/adapter/test_ai_adapter.py | 26 ++++++++++++++++++++++++++ 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/docs b/docs index 0c8169a26..2088a4a38 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 0c8169a263a922b85fff80aa33a40d84afa974c3 +Subproject commit 2088a4a389248dff08990f0cce56e68d8c00c032 diff --git a/ncatbot/adapter/ai/api/bot_api.py b/ncatbot/adapter/ai/api/bot_api.py index 582e1c73b..adc442519 100644 --- a/ncatbot/adapter/ai/api/bot_api.py +++ b/ncatbot/adapter/ai/api/bot_api.py @@ -331,6 +331,8 @@ async def chat_text( temperature: Optional[float] = None, max_tokens: Optional[int] = None, nickname_map: Optional[Dict[str, str]] = None, + mcp_servers: Optional[Dict[str, dict]] = None, + max_tool_calls: int = 10, **kwargs: Any, ) -> str: """Chat Completion — 直接返回文本 @@ -343,6 +345,8 @@ async def chat_text( temperature=temperature, max_tokens=max_tokens, nickname_map=nickname_map, + mcp_servers=mcp_servers, + max_tool_calls=max_tool_calls, **kwargs, ) return resp.choices[0].message.content or "" diff --git a/ncatbot/api/ai/interface.py b/ncatbot/api/ai/interface.py index dfb66d7fa..1e2b95880 100644 --- a/ncatbot/api/ai/interface.py +++ b/ncatbot/api/ai/interface.py @@ -33,6 +33,8 @@ async def chat( temperature: Optional[float] = None, max_tokens: Optional[int] = None, nickname_map: Optional[Dict[str, str]] = None, + mcp_servers: Optional[Dict[str, dict]] = None, + max_tool_calls: int = 10, **kwargs: Any, ) -> Any: """Chat Completion @@ -52,6 +54,11 @@ async def chat( 最大生成 token 数。 nickname_map: ``{user_id: 昵称}`` 映射,At 段转为可读文本。 + mcp_servers: + MCP 服务器配置字典(格式见 ``AIConfig.mcp_servers``)。 + 缺省使用配置中的 ``mcp_servers``;为空则不启用 MCP 工具。 + max_tool_calls: + 单轮对话中最多工具调用轮数(默认 10),防止死循环。 Returns ------- @@ -120,6 +127,8 @@ async def chat_text( temperature: Optional[float] = None, max_tokens: Optional[int] = None, nickname_map: Optional[Dict[str, str]] = None, + mcp_servers: Optional[Dict[str, dict]] = None, + max_tool_calls: int = 10, **kwargs: Any, ) -> str: """Chat Completion — 直接返回文本 diff --git a/tests/README.md b/tests/README.md index 868f254f2..f60011773 100644 --- a/tests/README.md +++ b/tests/README.md @@ -14,7 +14,7 @@ tests/ │ ├── service/ # 服务管理 + RBAC + 调度 (SM-01 ~ SM-08, SC-01 ~ SC-12, TS-01 ~ TS-06) │ ├── plugin/ # 插件 Mixin + 导入去重 + Loader (M-01 ~ M-41, ID-01 ~ ID-02, LD-01 ~ LD-05) │ ├── adapter/ # 适配器解析 + 注册表 + 真实数据 + 事件日志格式 (P-01 ~ P-07, RF-01 ~ RF-08, AR-01 ~ AR-05, SL-01 ~ SL-04, GM-01 ~ GM-05, BL-01 ~ BL-25, GH-01 ~ GH-11, LK-01 ~ LK-09, LKP-01 ~ LKP-10, ELS-01 ~ ELS-17) -│ ├── config/ # 配置迁移 + 安全 + 分层 + 事件日志格式 (CF-01 ~ CF-05, CS-01 ~ CS-05, CE-01 ~ CE-05, BQ-01 ~ BQ-11, AI-03 ~ AI-30, ELF-01 ~ ELF-06) +│ ├── config/ # 配置迁移 + 安全 + 分层 + 事件日志格式 (CF-01 ~ CF-05, CS-01 ~ CS-05, CE-01 ~ CE-05, BQ-01 ~ BQ-11, AI-03 ~ AI-31, ELF-01 ~ ELF-06) │ ├── cli/ # CLI 冒烟 (CX-01 ~ CX-22) │ └── webui/ # WebUI 单元测试 (WUI-01 ~ WUI-14) ├── integration/ # 集成测试 (I-01 ~ I-21, WUI-I-01 ~ WUI-I-04) @@ -105,7 +105,7 @@ python tests/e2e/napcat/run.py | LKE | 飞书事件实体 | LKE-01 ~ LKE-08 | | LKP | 飞书 PostBuilder & MessageArray 转换 | LKP-01 ~ LKP-10 | | BQ | Bilibili 查询 API (parse_bili_id / audio / subtitle) | BQ-01 ~ BQ-11 | -| AI | AI 适配器 (chat / image / ASR / MCP) | AI-03 ~ AI-30 | +| AI | AI 适配器 (chat / image / ASR / MCP) | AI-03 ~ AI-31 | | ELF | Event Log Format Config | ELF-01 ~ ELF-06 | | ELS | Event Log Summary | ELS-01 ~ ELS-17 | | WUI | WebUI 单元测试 | WUI-01 ~ WUI-14 | diff --git a/tests/unit/adapter/test_ai_adapter.py b/tests/unit/adapter/test_ai_adapter.py index c0330d55f..6e6385987 100644 --- a/tests/unit/adapter/test_ai_adapter.py +++ b/tests/unit/adapter/test_ai_adapter.py @@ -28,6 +28,7 @@ AI-26: MCP 工具加载与命名空间化({server}_{tool}) AI-27: MCP 工具调用返回文本结果 AI-28: MCP 单个服务器连接失败不影响其他服务器 + AI-31: chat_text() 透传 mcp_servers / max_tool_calls 给 chat() """ import asyncio @@ -965,3 +966,28 @@ def test_cli_configure_mcp_stdio(monkeypatch): "env": {"TOKEN": "abc"}, } } + + +# ---- AI-31 ---- + + +@pytest.mark.asyncio +async def test_chat_text_forwards_mcp_params(): + """AI-31: chat_text() 透传 mcp_servers / max_tool_calls 给 chat()""" + cfg = AIConfig(completion_model="gpt-4") + api = AIBotAPI(cfg) + + mock_resp = _make_response(content="北京天气晴朗", tool_calls=None) + + with patch.object(api, "chat", new_callable=AsyncMock) as mock_chat: + mock_chat.return_value = mock_resp + result = await api.chat_text( + "北京天气如何?", + mcp_servers={"weather": {"url": "http://x"}}, + max_tool_calls=5, + ) + + assert result == "北京天气晴朗" + call_kwargs = mock_chat.call_args.kwargs + assert call_kwargs["mcp_servers"] == {"weather": {"url": "http://x"}} + assert call_kwargs["max_tool_calls"] == 5 From 29f098ae289a6e2eee17363400adb3d22f68fd85 Mon Sep 17 00:00:00 2001 From: SilverLi Date: Fri, 7 Aug 2026 17:10:07 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=E5=9B=9E=E9=80=80=20frontmatter,?= =?UTF-8?q?=20package-lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs b/docs index 2088a4a38..5a9ce302a 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 2088a4a389248dff08990f0cce56e68d8c00c032 +Subproject commit 5a9ce302aedccd56aaed6587cbe8f45012f77adf