mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-03 13:55:00 +08:00
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:
192
app/agent/tools/projection.py
Normal file
192
app/agent/tools/projection.py
Normal 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",
|
||||
]
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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` 再执行快照测试及一次
|
||||
|
||||
@@ -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 服务器,将外部工具注入到智能助手工具列表中。当前支持:
|
||||
|
||||
@@ -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 热点文件
|
||||
|
||||
| 文件 | 行数 | 主要职责混合 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
346
tests/test_mcp_schema_budget.py
Normal file
346
tests/test_mcp_schema_budget.py
Normal 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"
|
||||
Reference in New Issue
Block a user