From 4f72b77ef08b7df4f95d7e7dc21159a81562f92c Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 23 Sep 2026 14:24:06 +0800 Subject: [PATCH] fix(agent): constrain API request body shapes --- app/agent/tools/impl/api.py | 15 ++++--- docs/mcp-api.md | 2 + skills/moviepilot-api/SKILL.md | 6 ++- tests/test_agent_api_gateway.py | 61 ++++++++++++++++++++++++-- tests/test_builtin_skill_boundaries.py | 2 +- 5 files changed, 73 insertions(+), 13 deletions(-) diff --git a/app/agent/tools/impl/api.py b/app/agent/tools/impl/api.py index 26c4e477c..cdab8f7d9 100644 --- a/app/agent/tools/impl/api.py +++ b/app/agent/tools/impl/api.py @@ -4,7 +4,7 @@ import json from copy import deepcopy from functools import lru_cache from pathlib import Path -from typing import Any, Dict, Optional, Type +from typing import Any, Dict, Literal, Optional, Type, Union from pydantic import BaseModel, Field, PrivateAttr @@ -23,6 +23,9 @@ _TOOL_MESSAGE_OPERATION_MAX_CHARS = 96 _TOOL_MESSAGE_PARAMETER_MAX_CHARS = 320 _SEARCH_TORRENTS_TOOL_TIMEOUT_SECONDS = 300.0 +# API 网关顶层请求体只开放对象、数组和 system.upgrade.dev 的固定字符串。 +MoviePilotApiBody = Union[Dict[str, JsonData], list[JsonData], Literal["dev"], None] + @lru_cache(maxsize=1) def _load_api_mcp_input_schema() -> dict[str, Any]: @@ -57,13 +60,13 @@ class MoviePilotApiInput(BaseModel): # type: ignore[misc] default_factory=dict, description="Query-string fields declared by the selected operation.", ) - body: JsonData = Field( + body: MoviePilotApiBody = Field( default=None, description=( - "JSON request value declared by the selected operation and its loaded Skill contract. " - "Most operations use an object; a oneOf branch may require an exact scalar. " - "Keep objects and arrays, including nested file items, as native JSON values; " - "never JSON-encode them into strings." + "Request body declared by the selected operation and its loaded Skill contract. " + "Pass objects and arrays, including nested file items, as native JSON values; " + "pass null only when the selected operation allows it. " + "The only string body is the exact value 'dev' for system.upgrade.dev." ), ) diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 969cdf8e6..0fe5a69b4 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -178,6 +178,8 @@ operation ID、权限、副作用、确认、恢复、结果敏感性及精确 只允许传 `tools/list` 对应 operation 分支中声明的 `path_params`、`query` 和 `body` 字段。不得传 URL、认证头、API Token 或任意 HTTP 方法。 +`body` 使用原生 JSON 值。对象和数组直接传入;仅在选定的 operation 允许时传 `null`。当前唯一的字符串请求体为 `system.upgrade.dev` 的固定值 `"dev"`。网关会按选定的 operation 合同校验请求体类型和字段。 + `search.torrents` 可能按媒体标题与别名分轮搜索站点:MoviePilot 内层请求最多等待 290 秒,Agent 工具总等待上限为 300 秒,预留 10 秒处理超时与返回结果;V3 MCP 包装层继续使用原有 `mcp_proxy_timeout` 配置(默认 600 秒)。其它 operation 沿用原有超时配置。`page` / `count` 只分页已完成的搜索结果,不会缩短站点搜索过程。 查询结果的兼容分页合同如下: diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 187ad7e21..18fc08503 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -1,6 +1,6 @@ --- name: moviepilot-api -version: 33 +version: 34 description: >- Use this skill for MoviePilot product operations such as media search, torrent search, downloads, subscriptions, library checks, sites, storage, workflows, @@ -91,7 +91,9 @@ use a more specific skill or explain that the structured operation is unavailabl 3. The selected category file already includes the shared body Models needed to construct its calls; do not load a second Models document. 4. Build one gateway call with only declared fields. Preserve source-native - identifiers and use the documented pagination fields. + identifiers and use the documented pagination fields. Pass object and array + bodies as native JSON values, and pass null only when the selected operation + allows it. The only string body is the literal `"dev"` for `system.upgrade.dev`. 5. Obtain confirmation for confirmation-protected or side-effecting operations, then execute the gateway call once. 6. Inspect `success`, `execution_outcome`, errors, empty results, and collection diff --git a/tests/test_agent_api_gateway.py b/tests/test_agent_api_gateway.py index 874a009f9..4c1cc6806 100644 --- a/tests/test_agent_api_gateway.py +++ b/tests/test_agent_api_gateway.py @@ -4,6 +4,8 @@ import re from types import SimpleNamespace from unittest.mock import AsyncMock, patch +import pytest + # pylint: disable=no-name-in-module # 策略包根通过 __getattr__ 惰性导出,Pylint 无法静态解析。 from app.agent.policy import ( DEFAULT_TOOL_POLICY_REGISTRY, @@ -194,14 +196,65 @@ def test_mcp_tools_list_preserves_all_moviepilot_api_operation_branches() -> Non assert operation_ids == set(API_OPERATION_ROUTES) -def test_local_agent_tool_uses_the_same_precise_operation_schema() -> None: - """本地 Agent 绑定的工具 schema 至少必须提示 body 是 JSON 结构值。""" +def test_local_agent_tool_rejects_json_encoded_object_bodies() -> None: + """本地 Agent 工具 schema 不得把 JSON 对象字符串化作为合法请求体。""" tool = MoviePilotApiTool(session_id="session", user_id="api_user") schema = tool.tool_call_schema assert not isinstance(schema, dict) - body = schema.model_json_schema()["properties"]["body"] + body_schema = schema.model_json_schema()["properties"]["body"] + body_branches = body_schema["anyOf"] + body_types = {branch.get("type") for branch in body_branches} - assert body["$ref"].endswith("/JsonData") + assert {"object", "array", "string", "null"}.issubset(body_types) + assert any(branch.get("const") == "dev" for branch in body_branches) + assert all( + branch.get("type") != "string" or branch.get("const") == "dev" + for branch in body_branches + ) + + body = { + "torrent_in": { + "title": "示例种子", + "enclosure": "https://example.com/test.torrent", + }, + } + canonical = tool.canonical_arguments({"operation_id": "download.add", "body": body}) + assert canonical["body"]["torrent_in"]["enclosure"] == body["torrent_in"]["enclosure"] + + with pytest.raises(ValueError): + tool.canonical_arguments( + { + "operation_id": "download.add", + "body": json.dumps(body, ensure_ascii=False), + } + ) + + +def test_operation_body_shapes_fit_local_agent_input_schema() -> None: + """所有 MCP operation 请求体必须可由内置 Agent 输入类型表达。""" + tool = MoviePilotApiTool(session_id="session", user_id="api_user") + api_schema = tool.get_mcp_input_schema() + definitions = api_schema.get("$defs", {}) + string_bodies = set() + + for operation in api_schema["oneOf"]: + properties = operation.get("properties", {}) + body_schema = properties.get("body") + if body_schema is None: + continue + while "$ref" in body_schema: + body_schema = definitions[body_schema["$ref"].rsplit("/", 1)[-1]] + variants = body_schema.get("anyOf", body_schema.get("oneOf", [body_schema])) + operation_id = properties["operation_id"]["const"] + for variant in variants: + while "$ref" in variant: + variant = definitions[variant["$ref"].rsplit("/", 1)[-1]] + if variant.get("type") == "string": + string_bodies.add((operation_id, variant.get("const"))) + else: + assert variant.get("type") in {"object", "array", "null"} + + assert string_bodies == {("system.upgrade.dev", "dev")} def test_mcp_collection_contract_distinguishes_exact_and_unavailable_totals() -> None: diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index a9ba62abf..1a00cd6e9 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -117,7 +117,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None: "command-dispatch": "2", "database-operation": "9", "feedback-issue": "10", - "moviepilot-api": "33", + "moviepilot-api": "34", "moviepilot-update": "5", "organize-files": "5", "transfer-failed-retry": "5",