diff --git a/app/agent/tools/impl/api.py b/app/agent/tools/impl/api.py index ff3fffaf0..1b3cb70c7 100644 --- a/app/agent/tools/impl/api.py +++ b/app/agent/tools/impl/api.py @@ -413,6 +413,9 @@ class MoviePilotApiTool(MoviePilotTool): """ 执行一个白名单 MoviePilot API 操作。 + HTTP/MCP/CLI 的直接调用不经过输入模型,因此也要还原顶层 JSON + 字符串请求体,并把还原后的值用于校验和实际请求。 + :param operation_id: 稳定 API 操作标识 :param path_params: 路径参数 :param query: 查询参数 @@ -430,6 +433,7 @@ class MoviePilotApiTool(MoviePilotTool): ensure_ascii=False, ) try: + body = coerce_string_body(body) arguments: dict[str, Any] = {"operation_id": operation_id, **kwargs} if path_params is not None: arguments["path_params"] = path_params diff --git a/docs/mcp-api.md b/docs/mcp-api.md index b3ab5110e..deb14f6dc 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -260,6 +260,7 @@ curl -sS "$MP_MCP_URL/tools/moviepilot_api/schema" \ - 不传 `url`、`method`、请求头或认证凭据;固定 operation 由服务端映射到业务接口。 - 不将 `media_id` 等业务字段平铺到顶层,也不为无请求体的操作补 `body: {}`。 - `body` 使用原生 JSON 对象或数组;只有合同允许时才传 `null`。当前字符串请求体仅用于 `system.upgrade.dev` 的固定值 `"dev"`。 +- 若模型把整个 `body` 编码成 JSON 字符串,各工具入口会在校验和发送前还原一层对象、数组或 `null`;嵌套字符串保持原值,非法请求体仍按 operation 合同拒绝。客户端应直接传原生 JSON 值。 - 媒体标识必须与返回它的 `media_source` 配对。不同操作的类型字段与枚举可能不同,不要把 `movie`、`电影` 或 `type`、`mtype`、`type_name` 相互套用。 - 下载、整理、订阅等操作优先复用前序查询返回的媒体上下文、种子上下文、存储项目和精确 ID,不根据标题猜测资源身份或文件路径。 diff --git a/scripts/generate_agent_skill_docs.py b/scripts/generate_agent_skill_docs.py index f3ba0ddad..309123bf7 100644 --- a/scripts/generate_agent_skill_docs.py +++ b/scripts/generate_agent_skill_docs.py @@ -17,7 +17,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -MOVIEPILOT_API_SKILL_VERSION = "60" +MOVIEPILOT_API_SKILL_VERSION = "61" DATABASE_OPERATION_SKILL_VERSION = "13" diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 0b9ec0bc6..d3f1fe3a7 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -1,6 +1,6 @@ --- name: moviepilot-api -version: 60 +version: 61 description: >- Use this skill for MoviePilot product operations such as media search, torrent search, downloads, subscriptions, library checks, sites, storage, workflows, @@ -154,6 +154,9 @@ downloader mappings when switching path modes. 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`. + If a model JSON-encodes the whole body, every gateway entrypoint restores one + layer of object, array, or null before validation and dispatch. Nested string + values stay unchanged, and the selected operation's contract still applies. Never flatten operation fields next to `operation_id`: put each in its declared `path_params`, `query`, or `body` container. Do not copy pagination or filters from another operation. An `invalid_input` response names the failing field diff --git a/tests/test_agent_api_bodies.py b/tests/test_agent_api_bodies.py new file mode 100644 index 000000000..6d9934c28 --- /dev/null +++ b/tests/test_agent_api_bodies.py @@ -0,0 +1,168 @@ +""" +验证 API 请求体在 Agent、MCP 和直接工具入口实际发送时保持合同类型。 +""" + +import json +from copy import deepcopy +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, Mock + +import pytest + +from app.agent.api.executor import ApiExecutionContext, MoviePilotApiExecutor +from app.agent.tools.impl.api import MoviePilotApiTool +from app.agent.tools.manager import MoviePilotToolsManager + + +async def _invoke_gateway( + entrypoint: str, gateway: MoviePilotApiTool, arguments: dict[str, Any], +) -> str: + """ + 从各公开入口进入同一真实网关,不跳过工具管理器的参数处理。 + + :param entrypoint: 直接调用、LangChain 或 HTTP/MCP/CLI 共用管理器入口 + :param gateway: 仅替换 HTTP 传输的真实网关 + :param arguments: 模型或客户端提交的原始参数 + :return: 供调用方读取的 JSON 结果 + """ + if entrypoint == "langchain": + return await gateway.ainvoke(arguments) + if entrypoint == "manager": + manager = MoviePilotToolsManager(session_id="body-test", user_id="1") + manager.tools = [gateway] + try: + return await manager.call_tool(gateway.name, arguments) + finally: + await manager.close() + return await gateway.run(**arguments) + + +@pytest.fixture +def api_transport(monkeypatch: pytest.MonkeyPatch) -> tuple[MoviePilotApiTool, AsyncMock]: + """ + 保留真实合同、权限和执行器,仅把令牌签发及 HTTP 出站替换为内存响应。 + """ + response = SimpleNamespace( + status_code=200, + headers={}, + json=lambda: {"success": True}, + aclose=AsyncMock(), + ) + request = AsyncMock(return_value=response) + executor = MoviePilotApiExecutor( + context=ApiExecutionContext(user_id="1", username="admin", is_admin=True), + request_factory=Mock(return_value=SimpleNamespace(request=request)), + ) + monkeypatch.setattr(executor, "_build_headers", lambda: {}) + monkeypatch.setattr(executor, "_resolve_base_url", lambda: "http://127.0.0.1:3001") + gateway = MoviePilotApiTool(session_id="body-test", user_id="1", executor=executor) + gateway.set_agent_context({"is_admin": True}) + return gateway, request + + +@pytest.mark.parametrize("entrypoint", ["direct", "langchain", "manager"]) +@pytest.mark.parametrize("json_encoded", [False, True], ids=["native", "encoded"]) +@pytest.mark.parametrize( + ("operation_id", "body"), + [ + ("download.add", {"torrent_in": { + "title": "天气之子", "enclosure": "https://example.invalid/test.torrent", + "description": "{\"原始副标题\": \"不应被递归解码\"}", + }, "media_source": "tmdb", "media_id": "568160"}), + ("transfer.history.delete", {"id": 7}), + ("slash.run", {"command": "/help"}), + ("media.classification.policy.preview", {"input": { + "kind": "media", "media": {"title": "天气之子"}, + }}), + ("site.priorities.update", [{"id": 3, "pri": 1}]), + ("system.restart", None), + ], +) +@pytest.mark.asyncio +async def test_gateway_sends_restored_body_to_http_transport( + api_transport: tuple[MoviePilotApiTool, AsyncMock], + entrypoint: str, + json_encoded: bool, + operation_id: str, + body: Any, +) -> None: + """ + 对象、数组和空值必须实际按原生类型发送,不能只在校验副本中还原。 + """ + gateway, request = api_transport + arguments = { + "operation_id": operation_id, + "body": json.dumps(body, ensure_ascii=False) if json_encoded else deepcopy(body), + } + original = deepcopy(arguments) + + result = await _invoke_gateway(entrypoint, gateway, arguments) + + assert json.loads(result)["success"] is True + request.assert_awaited_once() + assert request.call_args.kwargs["json"] == body + assert arguments == original + + +@pytest.mark.parametrize("entrypoint", ["direct", "langchain", "manager"]) +@pytest.mark.parametrize("json_encoded", [False, True], ids=["native", "encoded"]) +@pytest.mark.asyncio +async def test_gateway_restores_get_body_before_moving_it_to_query( + api_transport: tuple[MoviePilotApiTool, AsyncMock], + entrypoint: str, + json_encoded: bool, +) -> None: + """ + 兼容的 GET 对象请求体还原后才合并到查询参数,不得作为字符串发出。 + """ + gateway, request = api_transport + body = {"page": 2, "count": 20} + result = await _invoke_gateway(entrypoint, gateway, { + "operation_id": "site.rss", + "body": json.dumps(body) if json_encoded else body, + }) + + assert json.loads(result)["success"] is True + request.assert_awaited_once() + assert request.call_args.kwargs["json"] is None + assert request.call_args.kwargs["params"] == body + + +@pytest.mark.parametrize("entrypoint", ["direct", "langchain", "manager"]) +@pytest.mark.parametrize("body", [ + "not-json", "{", "true", "1", '"dev"', "null", "[]", + json.dumps(json.dumps({"torrent_in": {"title": "双重编码"}})), +]) +@pytest.mark.asyncio +async def test_gateway_rejects_invalid_bodies_before_http_request( + api_transport: tuple[MoviePilotApiTool, AsyncMock], entrypoint: str, body: str, +) -> None: + """ + 兼容还原只处理一层,错误形状、标量和非法 JSON 仍须在出站前拒绝。 + """ + gateway, request = api_transport + result = await _invoke_gateway(entrypoint, gateway, { + "operation_id": "download.add", "body": body, + }) + + assert json.loads(result)["error"] == "invalid_input" + request.assert_not_awaited() + + +@pytest.mark.parametrize("entrypoint", ["direct", "langchain", "manager"]) +@pytest.mark.asyncio +async def test_gateway_preserves_exact_dev_literal( + api_transport: tuple[MoviePilotApiTool, AsyncMock], entrypoint: str, +) -> None: + """ + 唯一合法的固定字符串请求体必须保持原值发送。 + """ + gateway, request = api_transport + result = await _invoke_gateway(entrypoint, gateway, { + "operation_id": "system.upgrade.dev", "body": "dev", + }) + + assert json.loads(result)["success"] is True + request.assert_awaited_once() + assert request.call_args.kwargs["json"] == "dev" diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index b36c7fbfb..ad78ef128 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -130,7 +130,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None: "database-operation": "13", "feedback-issue": "12", "anysearch": "3", - "moviepilot-api": "60", + "moviepilot-api": "61", "moviepilot-update": "7", "organize-files": "6", "transfer-failed-retry": "6",