fix(agent): constrain API request body shapes

This commit is contained in:
jxxghp
2026-09-23 14:24:06 +08:00
parent 6d6c142c5a
commit 4f72b77ef0
5 changed files with 73 additions and 13 deletions

View File

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

View File

@@ -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` 只分页已完成的搜索结果,不会缩短站点搜索过程。
查询结果的兼容分页合同如下:

View File

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

View File

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

View File

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