mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 21:07:01 +08:00
fix(agent): constrain API request body shapes
This commit is contained in:
@@ -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."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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` 只分页已完成的搜索结果,不会缩短站点搜索过程。
|
||||
|
||||
查询结果的兼容分页合同如下:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user