From 5ec9516a89986ccac31c3ca71619916533641a99 Mon Sep 17 00:00:00 2001 From: wenking <168186446+clone-fan@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:52:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(mcp):=20=E6=94=AF=E6=8C=81=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=20opt-in=20=E7=9A=84=20tools/list=20schema=20?= =?UTF-8?q?=E7=B4=A7=E5=87=91=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 默认保留 MCP tools/list 的完整 schema;显式携带 X-MCP-Schema-Mode: compact 时,为超限工具提供保留顶层对象和 operation/action 枚举的紧凑投影。REST 合同、tools/call 校验与权限判定沿用完整合同。 合并前修复嵌套定义引用与 patternProperties 投影边界,使用 app.agent.tools.projection 作为纯函数 owner,补充回归和相关 Skill 说明,并同步当前 v3 的架构快照与类型低水位。 验证:113 项相关回归、167 项架构策略测试、45 项架构快照测试通过;Pylint 10/10,类型、复杂度及并发等相关棘轮通过。最终 PR head 6d25e1039d7978c6a6accf4d6ef07a973a8790bf 的全部 10 项必需 GitHub 检查通过;待合并树与 CI 测试树一致。 投影是 best-effort,不承诺压入 5000 字节以内;未进行生产实例或镜像发布验证。 Co-authored-by: clone --- app/agent/tools/projection.py | 192 ++++++++++ app/api/endpoints/mcp.py | 56 ++- docs/architecture-overview.md | 8 +- docs/mcp-api.md | 13 + docs/refactor/optimization-checklist.md | 6 +- docs/rules/05-architecture.md | 1 + skills/downloader-operation/SKILL.md | 6 + skills/mediaserver-operation/SKILL.md | 6 + skills/moviepilot-api/SKILL.md | 6 +- .../architecture/dependency-baseline.json | 8 +- .../fixtures/architecture/mypy-baseline.json | 4 +- tests/test_architecture_dependencies.py | 13 + tests/test_mcp_schema_budget.py | 346 ++++++++++++++++++ 13 files changed, 645 insertions(+), 20 deletions(-) create mode 100644 app/agent/tools/projection.py create mode 100644 tests/test_mcp_schema_budget.py diff --git a/app/agent/tools/projection.py b/app/agent/tools/projection.py new file mode 100644 index 000000000..ea7db04f7 --- /dev/null +++ b/app/agent/tools/projection.py @@ -0,0 +1,192 @@ +"""外部 MCP ``tools/list`` 的 schema 预算投影(显式 opt-in)。 + +主流 MCP 客户端会对工具 schema 做有损压缩。以 Codex 为例,规范化后超过 5000 字节的 +schema 会依次执行「删除 description → 丢弃 ``$defs`` → 折叠深层复杂对象 → 移除组合 +关键字」,其中 ``oneOf``/``anyOf``/``allOf`` 所在的节点会被整段替换为 ``{}``。 +``moviepilot_api`` 等工具的完整合同远超该预算,且组合分支位于根节点,压缩后客户端 +只会拿到一个没有 ``type`` 的空对象;严格校验的上游随后以 +``schema must be a JSON Schema of 'type: "object"', got 'type: null'`` 拒绝整个请求, +使一次会话里所有工具调用都失败。 + +本模块提供显式 opt-in 的紧凑投影(``X-MCP-Schema-Mode: compact``):把序列化后超过 +客户端压缩阈值的工具 schema 投影为顶层对象合同——保留 ``type``/``required`` 与顶层 +属性(含 operation/action 枚举),去掉各级组合关键字、``$defs`` 与指向它们的失效 +本地引用,并把引用已删除分支的说明改写为不依赖分支的直接描述(无法改写的整条移除)。 +投影是 best-effort:它不承诺把结果压到阈值以内(``moviepilot_api`` 保留全部 231 个 +operation 的枚举本身就约占 5 KB),而是保证根节点不再是组合节点,客户端的后续压缩 +不会再把整份 schema 替换为空对象。逐 operation 的完整合同继续由 +``GET /api/v1/mcp/tools/{tool_name}/schema`` 与调用失败回执提供,调用侧校验行为不变; +默认(``full``)模式不投影,保持既有完整合同。 +""" + +from __future__ import annotations + +import json +from copy import deepcopy +from typing import Any, Mapping, Optional +from urllib.parse import unquote + +# Codex 等客户端对工具 schema 触发逐级压缩的字节阈值;超过时 compact 模式才会投影。 +MCP_CLIENT_SCHEMA_COMPACTION_BYTES = 5000 + +# 客户端压缩会把这些组合关键字所在节点整段替换为空对象,投影时提前删除。 +COMPOSITION_KEYWORDS = ("oneOf", "anyOf", "allOf") + +# 定义表在客户端压缩中会被丢弃,投影时不再保留无法解析的本地引用。 +DEFINITION_KEYWORDS = ("$defs", "definitions") + +# 投影会删除的关键字:指向它们的本地引用在投影后无法解析,必须一并去掉。 +PROJECTED_KEYWORDS = (*COMPOSITION_KEYWORDS, *DEFINITION_KEYWORDS) + +# 本地引用关键字;与 PROJECTED_KEYWORDS 组合判断指针是否已经失效。 +REFERENCE_KEYWORDS = ("$ref", "$dynamicRef") + +# 只遍历 schema 位置,避免把属性名、默认值或枚举中的同名业务字段当作关键字。 +SCHEMA_MAPPING_KEYWORDS = {"properties", "patternProperties", "dependentSchemas", "dependencies"} +SCHEMA_VALUE_KEYWORDS = { + "items", "prefixItems", "additionalItems", "additionalProperties", +} + +# 投影标记:客户端与测试据此识别本次返回的是紧凑投影而非完整合同。 +PROJECTION_MARKER = "x-moviepilot-schema-projection" + +# 投影会删除根级组合分支,引用这些分支的说明必须同步改写为直接描述。 +DESCRIPTION_REWRITES = { + "Select the oneOf branch matching operation_id and send exactly its documented fields. " + "Collection branches also describe their additive output metadata in x-moviepilot-collection.": ( + "Select the operation_id and send exactly that operation's documented fields. " + "Collection operations also describe their additive output metadata in " + "x-moviepilot-collection." + ), + "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.": ( + "Exact allowlisted MoviePilot operation ID." + ), + "Resource identities inserted only into the selected operation's fixed route placeholders. " + "Use the exact names and types in its oneOf branch.": ( + "Resource identities inserted only into the selected operation's fixed route placeholders; " + "exact names and types are defined by the selected operation." + ), + "Filters and control values sent in the query string. " + "Use the exact names, types, defaults, and enums in the selected operation's oneOf branch.": ( + "Filters and control values sent in the query string; exact names, types, defaults and " + "enums are defined by the selected operation." + ), + "Request value for the selected operation. Match the exact operation oneOf branch; " + "scalar and object request bodies are not interchangeable.": ( + "Request value for the selected operation; scalar and object request bodies are not " + "interchangeable." + ), +} + + +def serialized_schema_bytes(schema: Any) -> int: + """返回 schema 紧凑序列化后的字节数;无法序列化时按超出阈值处理。""" + try: + payload = json.dumps(schema, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + except (TypeError, ValueError): + return MCP_CLIENT_SCHEMA_COMPACTION_BYTES + 1 + return len(payload.encode("utf-8")) + + +def schema_within_client_budget(schema: Any) -> bool: + """判断 schema 是否在客户端压缩阈值以内。""" + return serialized_schema_bytes(schema) <= MCP_CLIENT_SCHEMA_COMPACTION_BYTES + + +def _rewrite_description(description: str) -> Optional[str]: + """改写引用已删除组合分支的说明;无法改写的引用直接移除。""" + rewrite = DESCRIPTION_REWRITES.get(description) + if rewrite is not None: + return rewrite + if any(keyword in description for keyword in COMPOSITION_KEYWORDS): + return None + return description + + +def _targets_projected_keyword(pointer: Any, removed_paths: set[tuple[str, ...]]) -> bool: + """按实际删除位置识别失效 JSON Pointer,同时保留名称恰好为关键字的属性引用。""" + if not isinstance(pointer, str) or not pointer.startswith("#/"): + return False + parts = tuple(part.replace("~1", "/").replace("~0", "~") for part in unquote(pointer[2:]).split("/")) + return any(parts[:length] in removed_paths for length in range(1, len(parts) + 1)) + + +def _project_schema_node( + node: Any, + path: tuple[str, ...], + removed_paths: set[tuple[str, ...]], + references: list[tuple[dict[str, Any], str, Any]], +) -> Any: + """只递归 schema 位置,并记录删除位置及引用,供整棵投影完成后清理前向引用。""" + if isinstance(node, list): + return [ + _project_schema_node(item, (*path, str(index)), removed_paths, references) + for index, item in enumerate(node) + ] + if not isinstance(node, Mapping): + return deepcopy(node) + projected: dict[str, Any] = {} + for key, value in node.items(): + if not isinstance(key, str): + continue + child_path = (*path, key) + if key in PROJECTED_KEYWORDS: + removed_paths.add(child_path) + continue + if key == "description" and isinstance(value, str): + rewritten = _rewrite_description(value) + if rewritten is not None: + projected[key] = rewritten + continue + if key in REFERENCE_KEYWORDS: + references.append((projected, key, value)) + if key in SCHEMA_MAPPING_KEYWORDS and isinstance(value, Mapping): + projected[key] = { + name: _project_schema_node(prop, (*child_path, name), removed_paths, references) + for name, prop in value.items() + if isinstance(name, str) + } + elif key in SCHEMA_VALUE_KEYWORDS: + projected[key] = _project_schema_node(value, child_path, removed_paths, references) + else: + projected[key] = deepcopy(value) + return projected + + +def project_schema_for_budget(schema: Mapping[str, Any]) -> dict[str, Any]: + """把超出客户端阈值的 schema 投影为顶层对象合同。""" + removed_paths: set[tuple[str, ...]] = {(key,) for key in PROJECTED_KEYWORDS} + references: list[tuple[dict[str, Any], str, Any]] = [] + projected = _project_schema_node(schema, (), removed_paths, references) + for node, key, pointer in references: + if _targets_projected_keyword(pointer, removed_paths): + del node[key] + if not isinstance(projected, dict): # pragma: no cover - 调用方已保证是对象 + projected = {} + projected.setdefault("type", "object") + projected[PROJECTION_MARKER] = { + "mode": "compact", + "reason": "client schema compaction replaces composition keywords with an empty schema", + "full_schema_endpoint": "/api/v1/mcp/tools/{tool_name}/schema", + } + return projected + + +def project_tool_schema(schema: Any) -> tuple[Any, bool]: + """按客户端压缩阈值返回 ``(schema, 是否投影)``;阈值内 schema 原样返回。""" + if not isinstance(schema, Mapping) or schema_within_client_budget(schema): + return schema, False + return project_schema_for_budget(schema), True + + +__all__ = [ + "COMPOSITION_KEYWORDS", + "DEFINITION_KEYWORDS", + "DESCRIPTION_REWRITES", + "MCP_CLIENT_SCHEMA_COMPACTION_BYTES", + "PROJECTION_MARKER", + "project_schema_for_budget", + "project_tool_schema", + "schema_within_client_budget", + "serialized_schema_bytes", +] diff --git a/app/api/endpoints/mcp.py b/app/api/endpoints/mcp.py index d14c3fb15..fbf998427 100644 --- a/app/api/endpoints/mcp.py +++ b/app/api/endpoints/mcp.py @@ -4,7 +4,8 @@ from fastapi import Depends, HTTPException, Request from fastapi.responses import JSONResponse, Response from app.adapters.web.security.access import verify_apikey -from app.agent.tools.manager import moviepilot_tool_manager +from app.agent.tools.manager import ToolDefinition, moviepilot_tool_manager +from app.agent.tools.projection import project_tool_schema from app.api.response import ( RAW_RESPONSE_OPENAPI_KEY, CompatibleCountParam, @@ -38,6 +39,14 @@ MCP_HIDDEN_TOOLS = { "read_skill", "view_image", } + +# 客户端 schema 模式:默认 full 保持完整 oneOf 合同;compact 为显式 opt-in, +# 把序列化后超出客户端压缩阈值的工具 schema 投影为顶层对象合同(见 projection 模块)。 +MCP_SCHEMA_MODE_FULL = "full" +MCP_SCHEMA_MODE_COMPACT = "compact" +MCP_SCHEMA_MODES = (MCP_SCHEMA_MODE_FULL, MCP_SCHEMA_MODE_COMPACT) +MCP_SCHEMA_MODE_HEADER = "X-MCP-Schema-Mode" + MCP_JSONRPC_ERROR_RESPONSES = { 400: {"model": _SchemaMcpJsonRpcError, "description": "JSON-RPC 请求错误"}, 401: {"model": _SchemaMcpJsonRpcError, "description": "JSON-RPC 认证失败"}, @@ -49,11 +58,41 @@ MCP_JSONRPC_ERROR_RESPONSES = { } -def list_exposed_tools(): +def list_exposed_tools() -> List[ToolDefinition]: """ 获取 MCP 可见工具列表 """ - return [tool for tool in moviepilot_tool_manager.list_tools() if tool.name not in MCP_HIDDEN_TOOLS] + tools: List[ToolDefinition] = list(moviepilot_tool_manager.list_tools()) + return [tool for tool in tools if tool.name not in MCP_HIDDEN_TOOLS] + + +def resolve_schema_mode(request: Request) -> str: + """读取客户端的 schema 模式;未声明或取值不支持时保持默认 full。""" + raw = request.headers.get(MCP_SCHEMA_MODE_HEADER) or "" + mode = raw.strip().lower() + return mode if mode in MCP_SCHEMA_MODES else MCP_SCHEMA_MODE_FULL + + +def list_client_tools(schema_mode: str = MCP_SCHEMA_MODE_FULL) -> List[ToolDefinition]: + """按客户端 schema 模式返回工具;compact 模式下投影超出客户端阈值的 schema。""" + tools = list_exposed_tools() + if schema_mode != MCP_SCHEMA_MODE_COMPACT: + return tools + + client_tools: List[ToolDefinition] = [] + projected_names: List[str] = [] + for tool in tools: + schema, projected = project_tool_schema(tool.input_schema) + if not projected: + client_tools.append(tool) + continue + projected_names.append(tool.name) + client_tools.append( + ToolDefinition(name=tool.name, description=tool.description, input_schema=schema) + ) + if projected_names: + logger.debug(f"MCP schema compact 投影:{', '.join(projected_names)}") + return client_tools def create_jsonrpc_response(request_id: Union[str, int, None], result: Any) -> Dict[str, Any]: @@ -84,6 +123,7 @@ async def _dispatch_jsonrpc_method( method: Any, params: Dict[str, Any], request_id: Union[str, int, None], + schema_mode: str = MCP_SCHEMA_MODE_FULL, ) -> Union[JSONResponse, Response]: """分派一个已经通过基础格式校验的 MCP JSON-RPC 方法。""" if method == "initialize": @@ -97,7 +137,7 @@ async def _dispatch_jsonrpc_method( content=create_jsonrpc_error(request_id, -32600, "initialized must be a notification"), ) if method == "tools/list": - result = await handle_tools_list() + result = await handle_tools_list(schema_mode) return JSONResponse(content=create_jsonrpc_response(request_id, result)) if method == "tools/call": result = await handle_tools_call(params) @@ -152,7 +192,7 @@ async def mcp_jsonrpc( request_id = body.get("id") try: - return await _dispatch_jsonrpc_method(method, params, request_id) + return await _dispatch_jsonrpc_method(method, params, request_id, schema_mode=resolve_schema_mode(request)) except ValueError as e: logger.warning(f"MCP 请求参数错误: {e}") @@ -204,11 +244,11 @@ async def handle_initialize(params: Dict[str, Any]) -> Dict[str, Any]: } -async def handle_tools_list() -> Dict[str, Any]: +async def handle_tools_list(schema_mode: str = MCP_SCHEMA_MODE_FULL) -> Dict[str, Any]: """ - 处理工具列表请求 + 处理工具列表请求(默认完整合同;compact 模式按客户端预算投影) """ - tools = list_exposed_tools() + tools = list_client_tools(schema_mode) # 转换为 MCP 工具格式 mcp_tools = [] diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 2d1a5c334..acd09cebd 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -762,8 +762,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 1121 | -| 内部导入边 | 9,563 | +| Python 模块 | 1122 | +| 内部导入边 | 9,564 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 49(债务已清零,49 条精确 containment) | @@ -773,7 +773,9 @@ flowchart LR | Model/Oper 自动事务与自建 Session | 0 | | 组合根外 `SystemConfigOper()` | 0 | -目录监控成功通知在批次完成后复用主事件循环开启静默窗口,新增 2 条合法 Chain 到 Runtime 依赖边(含父包展开),未新增模块或 SCC。成功通知聚合复用既有 owner 和领域模型,统一交由通知模板渲染;移除汇总发送端的模板上下文直连后,原聚合实现的 14 条新增依赖边减为 12 条,未新增模块或 SCC。社区勋章名单读取复用 `app.foundation.url` 标准化 GitHub 镜像地址,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,563;订阅独立日志复用运行时上下文、系统日志端口与资源鉴权,新增 19 条合法依赖边(含父包展开),未新增模块或 SCC;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条 Schema 到 Domain 的合法依赖边(含父包展开);种子下载限流窗口由 `app.domain.torrent` 定义,失败冷却 owner 复用同一语义,新增 1 条合法 Chain 到 Domain 的导入边;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条模块导入边;资源过滤动作 `app.workflow.actions.filter_torrents` 与动作包共用的领域还原函数同样把 Schema 资源还原为领域对象后再交给过滤链,新增 6 条模块导入边。音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 587、`app.factory` 为 600、`app.main` 为 602。专辑订阅入库对账新增 `app.chain.subscribe.music` 一个职责 owner;按注入的下载/整理历史 Port 读取事实,复用音频标签/CUE 读取和领域覆盖规则,依赖图增加 18 条合法边,不引入跨层数据库或 Adapter 直连。订阅业务活动新增 `app.application.subscription.activity` 及对应 DB Model、Oper、Adapter 四个模块,三入口与功能前提交对照均只增加这四个模块;性能基线仅同步结构计数,保留原有历史耗时采样和预算。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 +外部 MCP schema 紧凑投影由纯函数模块 `app.agent.tools.projection` 拥有,仅在请求头显式 opt-in 后应用;新增 1 个模块和 1 条合法端点依赖边,未新增 SCC。`list_exposed_tools` 返回类型注解使端点类型债务减少 5 项。 + +目录监控成功通知在批次完成后复用主事件循环开启静默窗口,新增 2 条合法 Chain 到 Runtime 依赖边(含父包展开),未新增模块或 SCC。成功通知聚合复用既有 owner 和领域模型,统一交由通知模板渲染;移除汇总发送端的模板上下文直连后,原聚合实现的 14 条新增依赖边减为 12 条,未新增模块或 SCC。社区勋章名单读取复用 `app.foundation.url` 标准化 GitHub 镜像地址,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,564;订阅独立日志复用运行时上下文、系统日志端口与资源鉴权,新增 19 条合法依赖边(含父包展开),未新增模块或 SCC;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条 Schema 到 Domain 的合法依赖边(含父包展开);种子下载限流窗口由 `app.domain.torrent` 定义,失败冷却 owner 复用同一语义,新增 1 条合法 Chain 到 Domain 的导入边;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条模块导入边;资源过滤动作 `app.workflow.actions.filter_torrents` 与动作包共用的领域还原函数同样把 Schema 资源还原为领域对象后再交给过滤链,新增 6 条模块导入边。音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 587、`app.factory` 为 600、`app.main` 为 602。专辑订阅入库对账新增 `app.chain.subscribe.music` 一个职责 owner;按注入的下载/整理历史 Port 读取事实,复用音频标签/CUE 读取和领域覆盖规则,依赖图增加 18 条合法边,不引入跨层数据库或 Adapter 直连。订阅业务活动新增 `app.application.subscription.activity` 及对应 DB Model、Oper、Adapter 四个模块,三入口与功能前提交对照均只增加这四个模块;性能基线仅同步结构计数,保留原有历史耗时采样和预算。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event 语义门禁,`Check host architecture snapshot` 再执行快照测试及一次 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 510e6e04c..da4508260 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -167,6 +167,8 @@ Python 内核继承宿主部署环境(含数据库配置与连接凭据), | `mediaserver_operation` | Emby、Jellyfin、Plex、ZSpace、UGREEN、TrimeMedia、Navidrome、Vyo 原生媒体库、搜索、播放、扫描和刷新操作 | `skills/mediaserver-operation/SKILL.md` 与 `skills/mediaserver-operation/scripts/mp-mediaserver.py` 的 `ACTIONS` | | `database_operation` | MoviePilot 配置数据库表清单、实时 schema、只读 SQL 和明确授权写入 | `skills/database-operation/SKILL.md` 与 `skills/database-operation/scripts/mp-db.py` 的 `ACTIONS` | +`tools/list` 默认返回上述完整合同,行为与既往一致。对工具 schema 体积敏感的客户端可以显式启用紧凑模式:请求时携带 `X-MCP-Schema-Mode: compact` 请求头。此时序列化后超过 5000 字节(Codex 等客户端触发逐级压缩的阈值)的工具 schema 会投影为顶层对象合同(根节点缺少 `type` 时补上 `object`)——保留 `type`、`required` 与顶层属性(含 `moviepilot_api` 完整的 231 项 `operation_id` 枚举),去掉各级 `oneOf`/`anyOf`/`allOf` 组合分支、`$defs` 定义表以及指向它的失效引用,并把引用已删除分支的属性说明改写为不依赖分支的直接描述(不在改写表内、仍引用分支的说明整条移除)。投影是 best-effort:`moviepilot_api` 投影后约 6 KB,仍高于该阈值(保留全部 operation 枚举本身就约占 5 KB),不承诺压入阈值以内;它的安全保证是根节点已不含组合关键字——客户端对超限 schema 的压缩不会再把整份 schema 替换为空对象,`type: object` 与 operation 枚举可继续用于发现与一次 `tools/call` 参数构造。逐 operation 的完整合同不受影响:`GET /api/v1/mcp/tools`、`GET /api/v1/mcp/tools/{tool_name}` 与 `GET /api/v1/mcp/tools/{tool_name}/schema` 始终返回完整合同;调用失败回执仍按所选 operation 返回精确参数合同,调用侧校验与权限判定不变。 + 这四个工具都要求管理员级 MCP 集成身份;`tools/list` 的可见性不等于绕过业务权限或写操作确认。下载器和媒体服务器工具会在一次调用内自动选择默认/唯一实例;实例不明确时,错误结果会列出可复用的精确实例名。数据库工具不接受任意连接串或凭据,脚本从 MoviePilot 运行时配置读取数据库连接。 `app/agent/policy/resources/api_mcp_schema.json` 是 `moviepilot_api` 的生成制品,不是设置项或 API 参数的手工事实源。`scripts/generate_agent_api_mcp_schema.py` 从当前 FastAPI OpenAPI、固定 operation 路由和 Agent 专用英文参数说明生成该文件;运行时直接读取它响应外部 MCP `tools/list`,测试会校验生成结果没有漂移。修改 API、请求模型或 operation 后应重新生成并提交该文件,不应直接编辑 JSON。 @@ -329,6 +331,17 @@ operation ID、权限、副作用、确认、恢复、结果敏感性及精确 } ``` +### Codex(CLI / IDE 扩展) + +Codex 会对超过 5000 字节的工具 schema 逐级压缩:`moviepilot_api` 的根级 `oneOf` 会被整段替换为空对象,使严格校验的上游以 400 拒绝整个请求,一次会话里的所有工具调用都会失败。让 Codex 显式启用紧凑投影: + +```toml +# ~/.codex/config.toml +[mcp_servers.moviepilot] +url = "http://localhost:3001/api/v1/mcp" +http_headers = { "X-API-KEY" = "your_api_key_here", "X-MCP-Schema-Mode" = "compact" } +``` + ## 4.1 Agent 外部 MCP Client 配置 MoviePilot 的内置 Agent 也可以作为 MCP Client 连接外部 MCP 服务器,将外部工具注入到智能助手工具列表中。当前支持: diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index aba12507d..8ed93dce9 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -100,7 +100,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1121 / 9,563 | `dependency-baseline.json` 当前快照;Telegram Rich Message 失败降级复用 `app.schemas.exception` 的 `ImmediateException`,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。目录监控成功通知在批次完成后复用主事件循环开启静默窗口,新增 2 条合法 Chain 到 Runtime 依赖边(含父包展开),未新增模块或 SCC。成功通知聚合复用既有 owner 和领域模型,统一交由通知模板渲染;移除汇总发送端的模板上下文直连后,原聚合实现的 14 条新增依赖边减为 12 条,未新增模块或 SCC。社区勋章名单读取复用 `app.foundation.url` 标准化 GitHub 镜像地址,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。专辑订阅入库对账新增 1 个 Chain owner 和 18 条合法依赖边(含父包展开),仍经注入 Port 读取历史并通过既有 mutation 写入;订阅业务活动新增 4 个模块和 50 条合法内部依赖边(含父包展开),Application 仅定义事实与持久化 Port,DB Adapter/Oper 实现存取,组合根注入,未增加 Application/Chain 到具体 Adapter 或数据库的直连;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条合法依赖边(含父包展开);种子失败冷却复用 `app.domain.torrent` 的限流语义,新增 1 条合法导入边;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条合法模块导入边,资源过滤动作复用同一还原再新增 6 条;LLM 独占连接关闭复用宿主线程池;歌词字形转换复用 `app.foundation.text`;订阅创建使用 `app.domain.context.MediaInfo` 和运行时配置端口,将非 TMDB 影视身份投影为 TMDB 身份;本次安全与可靠性修复新增 4 条合法依赖边:WebAgent 复用渠道类型、宿主历史清理复用文件系统代理、两个下载器复用纯领域种子哈希规则,未新增模块或 SCC;IP 归属地适配器复用 `app.schemas.common.JsonData` 声明原始 JSON 返回,新增 2 条合法类型依赖边(含父包展开),未新增模块、循环或越层依赖 | +| 宿主 Python 模块 / 内部依赖边 | 1122 / 9,564 | `dependency-baseline.json` 当前快照;Telegram Rich Message 失败降级复用 `app.schemas.exception` 的 `ImmediateException`,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。目录监控成功通知在批次完成后复用主事件循环开启静默窗口,新增 2 条合法 Chain 到 Runtime 依赖边(含父包展开),未新增模块或 SCC。成功通知聚合复用既有 owner 和领域模型,统一交由通知模板渲染;移除汇总发送端的模板上下文直连后,原聚合实现的 14 条新增依赖边减为 12 条,未新增模块或 SCC。社区勋章名单读取复用 `app.foundation.url` 标准化 GitHub 镜像地址,新增 2 条合法依赖边(含父包展开),未新增模块或 SCC。专辑订阅入库对账新增 1 个 Chain owner 和 18 条合法依赖边(含父包展开),仍经注入 Port 读取历史并通过既有 mutation 写入;订阅业务活动新增 4 个模块和 50 条合法内部依赖边(含父包展开),Application 仅定义事实与持久化 Port,DB Adapter/Oper 实现存取,组合根注入,未增加 Application/Chain 到具体 Adapter 或数据库的直连;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条合法依赖边(含父包展开);种子失败冷却复用 `app.domain.torrent` 的限流语义,新增 1 条合法导入边;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条合法模块导入边,资源过滤动作复用同一还原再新增 6 条;LLM 独占连接关闭复用宿主线程池;歌词字形转换复用 `app.foundation.text`;订阅创建使用 `app.domain.context.MediaInfo` 和运行时配置端口,将非 TMDB 影视身份投影为 TMDB 身份;本次安全与可靠性修复新增 4 条合法依赖边:WebAgent 复用渠道类型、宿主历史清理复用文件系统代理、两个下载器复用纯领域种子哈希规则,未新增模块或 SCC;IP 归属地适配器复用 `app.schemas.common.JsonData` 声明原始 JSON 返回,新增 2 条合法类型依赖边(含父包展开),未新增模块、循环或越层依赖 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -108,10 +108,12 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 372,347 行 | 本次重新扫描物理行数,排除 `app/plugins/**`;74 个文件超过 1,000 行,12 个超过 2,000 行;源码尺寸仅作观察 | | 长方法 | 309 个超过 80 行 | 本次 AST 扫描排除 `app/plugins/**`;61 个超过 150 行,17 个超过 250 行;新增函数仍受 C901/嵌套深度门禁约束 | -| 全量 mypy 历史债务 | 8,773 / 438 文件 | 通知排版补齐 Slack 消息块类型,历史债务减少 2 项;工作流资源过滤改用领域对象调用过滤链,恢复的节点输出在消费前归一化,并收紧可空资源列表的类型低水位;消息媒体交互类型债务已清零,canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降;微信 ClawBot 入站 item_list 媒体解析改为按子项键名分派后,类型错误减少 4 项;站点解析器分享率改用浮点初值,类型错误减少 20 项;本次下载与文件安全修复再减少 3 项;Transmission 进度回退值归一化再减少 2 项,逐文件逐错误码无增长 | +| 全量 mypy 历史债务 | 8,768 / 438 文件 | 通知排版补齐 Slack 消息块类型,历史债务减少 2 项;工作流资源过滤改用领域对象调用过滤链,恢复的节点输出在消费前归一化,并收紧可空资源列表的类型低水位;消息媒体交互类型债务已清零,canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降;微信 ClawBot 入站 item_list 媒体解析改为按子项键名分派后,类型错误减少 4 项;站点解析器分享率改用浮点初值,类型错误减少 20 项;本次下载与文件安全修复再减少 3 项;Transmission 进度回退值归一化再减少 2 项,逐文件逐错误码无增长 | | Ruff 历史诊断 | 98 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | +外部 MCP schema 紧凑投影由纯函数模块 `app.agent.tools.projection` 拥有,仅在请求头显式 opt-in 后应用;新增 1 个模块和 1 条合法端点依赖边,未新增 SCC。`list_exposed_tools` 返回类型注解使端点类型债务减少 5 项。 + ### 3.3 热点文件 | 文件 | 行数 | 主要职责混合 | diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index e5f30cd35..986ac56d9 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -116,6 +116,7 @@ to make the directory tree look symmetrical. | `app/agent/policy/api.py` | Fixed `moviepilot_api` operation registry, HTTP route templates and per-operation authorization/effect policy; no arbitrary URL or method input | | `app/agent/policy/mcp.py` | Generated external MCP input-contract builder for the fixed API registry; owns exact English oneOf parameter projection, not runtime authorization | | `app/agent/tools/impl/service.py` | Admin-only external MCP wrappers for downloader, media-server and database Skill scripts; synchronous scripts run only through the Agent blocking executor | +| `app/agent/tools/projection.py` | Pure MCP `tools/list` schema projection for clients that explicitly opt in via `X-MCP-Schema-Mode: compact`: contracts above the client compaction threshold are projected to an object schema (the root `type: object` is filled in when absent; operation/action enumerations preserved) so clients that strip composition keywords still receive usable parameters; full contracts stay on the REST tool endpoints and on the default `full` mode | `app.agent.AgentManager` remains the stable plugin-facing import and resolves lazily to `app.agent.manager.AgentManager`. Historical symbols formerly imported from diff --git a/skills/downloader-operation/SKILL.md b/skills/downloader-operation/SKILL.md index 6f1aef7ea..2620b602f 100644 --- a/skills/downloader-operation/SKILL.md +++ b/skills/downloader-operation/SKILL.md @@ -126,6 +126,12 @@ supported providers, effect, field types, required/default values, enums, and cross-field rules. The external client should select the matching branch and make one `tools/call`; it does not need to call a discovery tool first. +The default schema mode is `full`. Clients explicitly sending +`X-MCP-Schema-Mode: compact` receive a best-effort projection for oversized +schemas, without action-specific branches. Use this contract or the full +`GET /api/v1/mcp/tools/{tool_name}/schema` response for exact arguments; +call-time validation and authorization are unchanged. + MCP call arguments use the same contract without shell quoting: ```json diff --git a/skills/mediaserver-operation/SKILL.md b/skills/mediaserver-operation/SKILL.md index 53bf7fd61..b273c68b3 100644 --- a/skills/mediaserver-operation/SKILL.md +++ b/skills/mediaserver-operation/SKILL.md @@ -114,6 +114,12 @@ nested `metadata.refresh` item fields, and cross-field rules. The external client should select the matching branch and make one `tools/call`; it does not need to call a discovery tool first. +The default schema mode is `full`. Clients explicitly sending +`X-MCP-Schema-Mode: compact` receive a best-effort projection for oversized +schemas, without action-specific branches. Use this contract or the full +`GET /api/v1/mcp/tools/{tool_name}/schema` response for exact arguments; +call-time validation and authorization are unchanged. + MCP call arguments use the same contract without shell quoting: ```json diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index b66ef2c64..af936967c 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -228,7 +228,11 @@ When identity resolution fails, relay the gateway's actual remediation message a This Skill is the complete callable MoviePilot business API surface for the Agent. Every operation in `allowed-api-operations` has one exact parameter -contract in a category file and one matching MCP `tools/list` branch. There is +contract in a category file and one matching MCP `tools/list` branch in the +default `full` schema mode. Clients explicitly sending +`X-MCP-Schema-Mode: compact` receive a best-effort projection for oversized +schemas; exact operation contracts remain available here and through +`GET /api/v1/mcp/tools/moviepilot_api/schema`. There is no hidden fallback to an arbitrary REST route. MoviePilot's underlying OpenAPI document is larger because it also serves the diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index d7ad7bd51..a0d3f6a2a 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -941,8 +941,8 @@ "runtime_only": true } }, - "edge_count": 9563, - "edge_sha256": "8fc47a0f231e6f7bcb13c591365ded68951b1186b8d438be6e5af40fdb807719", + "edge_count": 9564, + "edge_sha256": "20aa028ee1ffe44f9dc04a763770593f1f79ca776e1e65ab632fbbbfee9ab989", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -2582,6 +2582,7 @@ "app.api.endpoints.mcp -> app.agent", "app.api.endpoints.mcp -> app.agent.tools", "app.api.endpoints.mcp -> app.agent.tools.manager", + "app.api.endpoints.mcp -> app.agent.tools.projection", "app.api.endpoints.mcp -> app.api", "app.api.endpoints.mcp -> app.api.response", "app.api.endpoints.mcp -> app.runtime", @@ -10508,7 +10509,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 1121, + "module_count": 1122, "modules": [ "app", "app.adapters", @@ -10696,6 +10697,7 @@ "app.agent.tools.impl.view_image", "app.agent.tools.impl.write_file", "app.agent.tools.manager", + "app.agent.tools.projection", "app.agent.tools.result", "app.agent.tools.tags", "app.agent.web", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index c2d88379a..b92119608 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -400,9 +400,7 @@ "app/api/endpoints/mcp.py": { "assignment": 3, "index": 1, - "misc": 6, - "no-untyped-call": 4, - "no-untyped-def": 1 + "misc": 6 }, "app/api/endpoints/media.py": { "arg-type": 2, diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index ac97e797c..18dfed5a6 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -354,6 +354,19 @@ def test_agent_manager_uses_focused_owner_modules_and_precise_facade(): assert "from app.agent.orchestrator import AgentManager" not in startup_source +def test_mcp_schema_projection_has_one_pure_owner() -> None: + """MCP schema 投影使用单词 owner,且不引入运行时或端点依赖。""" + owner = APP_ROOT / "agent" / "tools" / "projection.py" + assert owner.is_file() + assert not (owner.parent / "schema_budget.py").exists() + imports = [ + node.module or "" + for node in ast.walk(_parse_source(owner)) + if isinstance(node, ast.ImportFrom) + ] + assert not any(name == "app" or name.startswith("app.") for name in imports) + + def test_runtime_dependencies_use_same_named_single_word_package() -> None: """运行依赖能力必须使用同名包,且宿主直接依赖单一职责子模块。""" package = APP_ROOT / "runtime" / "dependencies" diff --git a/tests/test_mcp_schema_budget.py b/tests/test_mcp_schema_budget.py new file mode 100644 index 000000000..a02a7279f --- /dev/null +++ b/tests/test_mcp_schema_budget.py @@ -0,0 +1,346 @@ +"""外部 MCP tools/list 的 schema 预算投影(compact opt-in)测试。""" + +import asyncio +import json +from copy import deepcopy +from pathlib import Path +from typing import Any, Awaitable, Callable, Optional + +import pytest +from starlette.requests import Request + +from app.agent.policy.api import API_OPERATION_ROUTES +from app.agent.tools.base import MoviePilotTool +from app.agent.tools.impl.api import MoviePilotApiTool +from app.agent.tools.manager import MoviePilotToolsManager +from app.agent.tools.projection import ( + PROJECTION_MARKER, + project_schema_for_budget, + project_tool_schema, + schema_within_client_budget, + serialized_schema_bytes, +) +from app.api.endpoints import mcp + + +class OversizedSchemaTool(MoviePilotTool): + """测试用超大 MCP schema 工具。""" + + name: str = "oversized_schema_tool" + description: str = "测试用超大 MCP schema 工具" + + def get_mcp_input_schema(self) -> dict[str, Any]: + """返回超出客户端压缩阈值的根级 oneOf 合同。""" + actions = [f"action.{index}" for index in range(40)] + return { + "type": "object", + "properties": {"action": {"type": "string", "enum": actions}}, + "required": ["action"], + "oneOf": [ + { + "type": "object", + "title": action, + "description": "分支说明" * 12, + "properties": {"action": {"const": action}, "arguments": {"type": "object"}}, + "required": ["action"], + } + for action in actions + ], + } + + async def run(self, **kwargs) -> str: + """返回固定测试结果。""" + return "ok" + + +def _receive_with_body(body: bytes) -> Callable[[], Awaitable[dict[str, Any]]]: + """构造只投递一次请求体的 receive 通道。""" + + async def receive() -> dict[str, Any]: + """向测试请求提供预设的完整请求体。""" + return {"type": "http.request", "body": body, "more_body": False} + + return receive + + +def _request(headers: Optional[list[tuple[bytes, bytes]]] = None, body: bytes = b"") -> Request: + """构造测试用 HTTP 请求。""" + return Request( + { + "type": "http", + "method": "POST", + "path": "/api/v1/mcp", + "headers": headers or [], + "query_string": b"", + }, + _receive_with_body(body), + ) + + +def _manager_with(tool: MoviePilotTool) -> MoviePilotToolsManager: + """构造只包含指定工具的隔离 MCP 工具管理器。""" + manager = MoviePilotToolsManager(session_id="schema-budget-test", user_id="api_user") + manager.tools = [tool] + return manager + + +def _api_schema() -> dict[str, Any]: + """读取运行时生成的 moviepilot_api 完整输入合同。""" + schema_path = Path(__file__).parents[1] / "app/agent/policy/resources/api_mcp_schema.json" + return json.loads(schema_path.read_text(encoding="utf-8")) + + +def test_oversized_schema_is_projected_to_object_contract() -> None: + """超出阈值的 schema 必须投影为可被严格客户端接受的顶层对象合同。""" + schema = OversizedSchemaTool(session_id="session", user_id="api_user").get_mcp_input_schema() + assert not schema_within_client_budget(schema) + + projected, changed = project_tool_schema(schema) + + assert changed is True + assert projected["type"] == "object" + assert "oneOf" not in projected + assert projected["required"] == ["action"] + assert projected["properties"]["action"]["enum"] == schema["properties"]["action"]["enum"] + assert projected[PROJECTION_MARKER]["mode"] == "compact" + assert serialized_schema_bytes(projected) < serialized_schema_bytes(schema) + + +def test_schema_within_budget_is_returned_untouched() -> None: + """阈值内的 schema 不得被投影,保持既有完整合同。""" + schema = {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]} + assert schema_within_client_budget(schema) + + projected, changed = project_tool_schema(schema) + + assert changed is False + assert projected is schema + + +def test_moviepilot_api_projection_keeps_discovery_and_cleans_branch_descriptions() -> None: + """moviepilot_api 投影后保留 operation 枚举,且不再残留指向已删除分支的说明。""" + schema = _api_schema() + assert not schema_within_client_budget(schema) + + projected = project_schema_for_budget(schema) + + assert projected["type"] == "object" + for keyword in ("oneOf", "anyOf", "allOf", "$defs"): + assert keyword not in projected + properties = projected["properties"] + assert set(properties["operation_id"]["enum"]) == set(schema["properties"]["operation_id"]["enum"]) + assert {"path_params", "query", "body"}.issubset(properties) + assert "oneOf" not in projected["description"] + serialized = json.dumps(projected, ensure_ascii=False) + assert "oneOf" not in serialized + assert "$ref" not in serialized + assert serialized_schema_bytes(projected) < serialized_schema_bytes(schema) // 20 + + +def test_projection_does_not_leave_dangling_definition_refs() -> None: + """定义表和组合分支随投影删除后,指向它们的本地引用必须一并去掉,只保留兄弟信息。""" + schema = { + "type": "object", + "properties": { + "payload": {"$ref": "#/$defs/Payload", "description": "自由格式业务参数"}, + "branch": {"$dynamicRef": "#/oneOf/0/properties/action"}, + "legacy": {"$ref": "#/definitions"}, + "keep": {"$ref": "#/properties/kept"}, + "kept": {"type": "string"}, + }, + "required": ["payload"], + "$defs": {"Payload": {"type": "object", "properties": {"name": {"type": "string"}}}}, + "oneOf": [{"required": ["payload"]}], + "description": "选择 oneOf 分支", + } + + projected = project_schema_for_budget(schema) + + assert "$defs" not in projected + assert "oneOf" not in projected + assert projected["properties"]["payload"] == {"description": "自由格式业务参数"} + assert projected["properties"]["branch"] == {} + assert projected["properties"]["legacy"] == {} + assert projected["properties"]["keep"] == {"$ref": "#/properties/kept"} + assert projected["properties"]["kept"] == {"type": "string"} + + +def test_projection_adds_missing_root_object_type() -> None: + """根节点缺少 ``type`` 时补 ``type: object``,已声明的根类型不被改写。""" + without_type = {"properties": {"payload": {"type": "string"}}} + projected = project_schema_for_budget(without_type) + assert projected["type"] == "object" + assert projected["properties"] == {"payload": {"type": "string"}} + + declared_array = {"type": "array", "items": {"type": "string"}} + assert project_schema_for_budget(declared_array)["type"] == "array" + + +@pytest.mark.parametrize("keyword", ["patternProperties", "dependentSchemas", "dependencies"]) +def test_projection_cleans_nested_schema_maps_without_changing_field_names(keyword: str) -> None: + """schema 映射中的分支要清理,但同名业务字段与默认值必须原样保留。""" + schema = { + "type": "object", + keyword: {"oneOf": {"anyOf": [{"type": "string"}, {"type": "null"}]}}, + "properties": {"oneOf": {"type": "string"}}, + "default": {"oneOf": ["business value"]}, + "enum": [{"$defs": {"payload": 1}}], + } + original = deepcopy(schema) + + projected = project_schema_for_budget(schema) + + assert projected[keyword] == {"oneOf": {}} + assert projected["properties"] == schema["properties"] + assert projected["default"] == schema["default"] + assert projected["enum"] == schema["enum"] + assert schema == original + + +def test_projection_removes_nested_and_escaped_definition_refs() -> None: + """清理嵌套定义的前向引用,并正确区分 JSON Pointer 转义与合法同名属性。""" + schema = { + "type": "object", + "properties": { + "reference": {"$ref": "#/properties/a~1b~0c/$defs/Text", "description": "业务参数"}, + "encoded": {"$dynamicRef": "#/properties/a~1b~0c/%24defs/Text"}, + "kept": {"$ref": "#/properties/a~1b~0c/properties/oneOf"}, + "a/b~c": { + "$defs": {"Text": {"type": "string"}}, + "properties": {"oneOf": {"type": "string"}}, + }, + }, + } + + projected = project_schema_for_budget(schema) + + assert projected["properties"]["reference"] == {"description": "业务参数"} + assert projected["properties"]["encoded"] == {} + assert projected["properties"]["kept"] == schema["properties"]["kept"] + assert "$defs" not in projected["properties"]["a/b~c"] + + +@pytest.mark.parametrize("keyword", ["prefixItems", "items", "additionalItems", "additionalProperties"]) +def test_projection_cleans_compositions_in_subschemas(keyword: str) -> None: + """数组元素及附加属性子 schema 中的组合关键字也不能残留。""" + child = {"allOf": [{"type": "string"}]} + schema = {"type": "object", keyword: [child] if keyword == "prefixItems" else child} + + projected = project_schema_for_budget(schema) + + assert projected[keyword] == ([{}] if keyword == "prefixItems" else {}) + + +def test_handle_tools_list_keeps_full_moviepilot_api_one_of_by_default(monkeypatch) -> None: + """handle_tools_list 默认返回完整 oneOf;compact 模式才投影。""" + manager = _manager_with(MoviePilotApiTool(session_id="session", user_id="api_user")) + monkeypatch.setattr(mcp, "moviepilot_tool_manager", manager) + + tools = asyncio.run(mcp.handle_tools_list())["tools"] + api_tool = next(tool for tool in tools if tool["name"] == "moviepilot_api") + branches = api_tool["inputSchema"]["oneOf"] + operation_ids = {branch["properties"]["operation_id"]["const"] for branch in branches} + assert operation_ids == set(API_OPERATION_ROUTES) + + compacted = asyncio.run(mcp.handle_tools_list(mcp.MCP_SCHEMA_MODE_COMPACT))["tools"] + compacted_api = next(tool for tool in compacted if tool["name"] == "moviepilot_api") + assert "oneOf" not in compacted_api["inputSchema"] + assert set(compacted_api["inputSchema"]["properties"]["operation_id"]["enum"]) == set(API_OPERATION_ROUTES) + + +def test_tools_list_compact_mode_projects_oversized_tools(monkeypatch) -> None: + """compact 模式通过 handle_tools_list 投影超大 schema,默认模式完整返回。""" + manager = _manager_with(OversizedSchemaTool(session_id="session", user_id="api_user")) + monkeypatch.setattr(mcp, "moviepilot_tool_manager", manager) + + listed = asyncio.run(mcp.handle_tools_list())["tools"] + assert [tool["name"] for tool in listed] == ["oversized_schema_tool"] + assert "oneOf" in listed[0]["inputSchema"] + assert PROJECTION_MARKER not in listed[0]["inputSchema"] + + compacted = asyncio.run(mcp.handle_tools_list(mcp.MCP_SCHEMA_MODE_COMPACT))["tools"] + assert "oneOf" not in compacted[0]["inputSchema"] + assert PROJECTION_MARKER in compacted[0]["inputSchema"] + + +def test_jsonrpc_tools_list_honours_compact_header(monkeypatch) -> None: + """JSON-RPC tools/list 仅在显式 compact 请求头下投影,未知取值与其它方法不受影响。""" + manager = _manager_with(OversizedSchemaTool(session_id="session", user_id="api_user")) + monkeypatch.setattr(mcp, "moviepilot_tool_manager", manager) + body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}).encode("utf-8") + + default_response = asyncio.run(mcp.mcp_jsonrpc(_request(body=body))) + default_tools = json.loads(bytes(default_response.body).decode("utf-8"))["result"]["tools"] + assert "oneOf" in default_tools[0]["inputSchema"] + + compact_response = asyncio.run( + mcp.mcp_jsonrpc(_request(headers=[(b"x-mcp-schema-mode", b"compact")], body=body)) + ) + compact_tools = json.loads(bytes(compact_response.body).decode("utf-8"))["result"]["tools"] + assert "oneOf" not in compact_tools[0]["inputSchema"] + assert PROJECTION_MARKER in compact_tools[0]["inputSchema"] + + unknown_response = asyncio.run( + mcp.mcp_jsonrpc(_request(headers=[(b"x-mcp-schema-mode", b"unknown")], body=body)) + ) + unknown_tools = json.loads(bytes(unknown_response.body).decode("utf-8"))["result"]["tools"] + assert "oneOf" in unknown_tools[0]["inputSchema"] + + initialize_body = json.dumps( + {"jsonrpc": "2.0", "id": 2, "method": "initialize", "params": {}} + ).encode("utf-8") + initialize_response = asyncio.run( + mcp.mcp_jsonrpc( + _request(headers=[(b"x-mcp-schema-mode", b"compact")], body=initialize_body) + ) + ) + assert "protocolVersion" in json.loads(bytes(initialize_response.body).decode("utf-8"))["result"] + + call_body = json.dumps( + { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "read_skill", "arguments": {}}, + } + ).encode("utf-8") + call_results = [ + json.loads( + bytes( + asyncio.run( + mcp.mcp_jsonrpc(_request(headers=headers, body=call_body)) + ).body + ).decode("utf-8") + )["result"] + for headers in (None, [(b"x-mcp-schema-mode", b"compact")]) + ] + assert call_results[0] == call_results[1] + assert call_results[0]["isError"] is True + + +def test_rest_tool_list_always_returns_full_contract(monkeypatch) -> None: + """三个 REST 工具端点不受客户端 schema 模式影响,始终返回完整合同。""" + manager = _manager_with(OversizedSchemaTool(session_id="session", user_id="api_user")) + monkeypatch.setattr(mcp, "moviepilot_tool_manager", manager) + + tools = asyncio.run(mcp.list_tools(None)) + + assert "oneOf" in tools[0]["inputSchema"] + assert PROJECTION_MARKER not in tools[0]["inputSchema"] + + info = asyncio.run(mcp.get_tool_info("oversized_schema_tool", None)) + assert "oneOf" in info["inputSchema"] + assert PROJECTION_MARKER not in info["inputSchema"] + + schema = asyncio.run(mcp.get_tool_schema("oversized_schema_tool", None)) + assert "oneOf" in schema + assert PROJECTION_MARKER not in schema + + +def test_resolve_schema_mode_defaults_to_full() -> None: + """schema 模式读取请求头,未知或缺失取值回落到 full。""" + assert mcp.resolve_schema_mode(_request([(b"x-mcp-schema-mode", b"compact")])) == "compact" + assert mcp.resolve_schema_mode(_request([(b"x-mcp-schema-mode", b" COMPACT ")])) == "compact" + assert mcp.resolve_schema_mode(_request([(b"x-mcp-schema-mode", b"full")])) == "full" + assert mcp.resolve_schema_mode(_request([(b"x-mcp-schema-mode", b"unknown")])) == "full" + assert mcp.resolve_schema_mode(_request()) == "full"