fix(mcp): 支持显式 opt-in 的 tools/list schema 紧凑投影

默认保留 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 6d25e1039d 的全部 10 项必需 GitHub 检查通过;待合并树与 CI 测试树一致。

投影是 best-effort,不承诺压入 5000 字节以内;未进行生产实例或镜像发布验证。

Co-authored-by: clone <clone_fan@foxmail.com>
This commit is contained in:
wenking
2026-10-16 19:52:57 +08:00
committed by GitHub
parent 3aac2daa9f
commit 5ec9516a89
13 changed files with 645 additions and 20 deletions

View File

@@ -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",
]

View File

@@ -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 = []

View File

@@ -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` 再执行快照测试及一次

View File

@@ -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 服务器,将外部工具注入到智能助手工具列表中。当前支持:

View File

@@ -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 热点文件
| 文件 | 行数 | 主要职责混合 |

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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,

View File

@@ -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"

View File

@@ -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"