mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
fix(cli): unwrap MCP REST tool responses
This commit is contained in:
50
app/cli.py
50
app/cli.py
@@ -670,6 +670,7 @@ def _print_json(value: Any) -> None:
|
||||
|
||||
|
||||
def _parse_tool_result(result: Any) -> Any:
|
||||
"""解析工具返回的 JSON 文本,普通文本保持原样。"""
|
||||
if not isinstance(result, str):
|
||||
return result
|
||||
try:
|
||||
@@ -679,13 +680,26 @@ def _parse_tool_result(result: Any) -> Any:
|
||||
|
||||
|
||||
def _tool_request_headers(runtime: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
|
||||
"""使用本地 API_TOKEN 认证工具请求,缺少配置时明确报错。"""
|
||||
api_token = _runtime_api_token(runtime)
|
||||
if not api_token:
|
||||
raise click.ClickException("本地配置中未找到 API_TOKEN,请先配置后再使用 tool/scheduler 命令")
|
||||
return {"X-API-KEY": api_token}
|
||||
|
||||
|
||||
def _tool_response_data(response: Dict[str, Any], error_message: str) -> Any:
|
||||
"""校验工具 REST 响应并解包;错误只显示原因,避免倾倒大体积响应。"""
|
||||
payload = response.get("json")
|
||||
if not isinstance(payload, dict):
|
||||
raise click.ClickException(f"{error_message}(HTTP {response['status']},响应格式错误)")
|
||||
if response["status"] not in {200, 201} or "success" not in payload:
|
||||
message = payload.get("message") or payload.get("error") or payload.get("detail")
|
||||
raise click.ClickException(message or f"{error_message}(HTTP {response['status']})")
|
||||
return _unwrap_api_data(payload)
|
||||
|
||||
|
||||
def _call_tool(tool_name: str, arguments: Dict[str, Any], runtime: Optional[Dict[str, Any]] = None) -> Any:
|
||||
"""解包 REST 的 data.result,再解析工具自身的文本或业务响应。"""
|
||||
response = _http_request(
|
||||
"POST",
|
||||
"/api/v1/mcp/tools/call",
|
||||
@@ -694,16 +708,14 @@ def _call_tool(tool_name: str, arguments: Dict[str, Any], runtime: Optional[Dict
|
||||
timeout=30.0,
|
||||
runtime=runtime,
|
||||
)
|
||||
payload = response.get("json") or {}
|
||||
if response["status"] not in {200, 201}:
|
||||
message = payload.get("error") or payload.get("detail") or response["text"] or "调用工具失败"
|
||||
raise click.ClickException(message)
|
||||
if not payload.get("success"):
|
||||
raise click.ClickException(payload.get("error") or "调用工具失败")
|
||||
return _parse_tool_result(payload.get("result"))
|
||||
data = _tool_response_data(response, "调用工具失败")
|
||||
if not isinstance(data, dict) or not isinstance(data.get("result"), str):
|
||||
raise click.ClickException("调用工具失败:响应缺少有效的 data.result")
|
||||
return _parse_tool_result(data["result"])
|
||||
|
||||
|
||||
def _load_tool(tool_name: str, runtime: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""读取统一 REST 信封中的工具详情,并保留工具不存在的错误提示。"""
|
||||
response = _http_request(
|
||||
"GET",
|
||||
f"/api/v1/mcp/tools/{tool_name}",
|
||||
@@ -713,12 +725,14 @@ def _load_tool(tool_name: str, runtime: Optional[Dict[str, Any]] = None) -> Dict
|
||||
)
|
||||
if response["status"] == 404:
|
||||
raise click.ClickException(f"工具不存在:{tool_name}")
|
||||
if response["status"] != 200 or not isinstance(response.get("json"), dict):
|
||||
raise click.ClickException(response["text"] or f"获取工具失败(HTTP {response['status']})")
|
||||
return response["json"]
|
||||
data = _tool_response_data(response, "获取工具失败")
|
||||
if not isinstance(data, dict):
|
||||
raise click.ClickException("获取工具失败:响应 data 不是工具对象")
|
||||
return data
|
||||
|
||||
|
||||
def _load_tools(runtime: Optional[Dict[str, Any]] = None) -> list[Dict[str, Any]]:
|
||||
"""读取统一 REST 信封中的工具列表,空列表保持合法。"""
|
||||
response = _http_request(
|
||||
"GET",
|
||||
"/api/v1/mcp/tools",
|
||||
@@ -726,9 +740,10 @@ def _load_tools(runtime: Optional[Dict[str, Any]] = None) -> list[Dict[str, Any]
|
||||
timeout=10.0,
|
||||
runtime=runtime,
|
||||
)
|
||||
if response["status"] != 200 or not isinstance(response.get("json"), list):
|
||||
raise click.ClickException(response["text"] or f"获取工具列表失败(HTTP {response['status']})")
|
||||
return response["json"]
|
||||
data = _tool_response_data(response, "获取工具列表失败")
|
||||
if not isinstance(data, list):
|
||||
raise click.ClickException("获取工具列表失败:响应 data 不是工具列表")
|
||||
return data
|
||||
|
||||
|
||||
def _normalize_type(schema: Optional[Dict[str, Any]]) -> str:
|
||||
@@ -742,6 +757,7 @@ def _normalize_type(schema: Optional[Dict[str, Any]]) -> str:
|
||||
|
||||
|
||||
def _format_tool_detail(tool: Dict[str, Any]) -> None:
|
||||
"""展示工具定义及参数的必填标记,供 CLI 用户构造调用。"""
|
||||
click.echo(f"Command: {tool.get('name')}")
|
||||
click.echo(f"Description: {tool.get('description') or '(none)'}")
|
||||
click.echo("")
|
||||
@@ -1464,7 +1480,9 @@ def tool_run(tool_name: str, args: tuple[str, ...]) -> None:
|
||||
"""运行指定工具"""
|
||||
arguments = _parse_key_value_pairs(args)
|
||||
result = _call_tool(tool_name, arguments, runtime=_backend_runtime())
|
||||
if isinstance(result, (dict, list)):
|
||||
if result is None or result == "":
|
||||
click.echo("工具执行成功(无返回内容)")
|
||||
elif isinstance(result, (dict, list)):
|
||||
_print_json(result)
|
||||
else:
|
||||
click.echo(result)
|
||||
@@ -1506,7 +1524,9 @@ def scheduler_run(job_id: str) -> None:
|
||||
runtime=_backend_runtime(),
|
||||
)
|
||||
)
|
||||
if isinstance(result, (dict, list)):
|
||||
if result is None or result == "":
|
||||
click.echo("调度任务调用成功(无返回内容)")
|
||||
elif isinstance(result, (dict, list)):
|
||||
_print_json(result)
|
||||
else:
|
||||
click.echo(result)
|
||||
|
||||
@@ -602,6 +602,9 @@ moviepilot tool run moviepilot_api operation_id=media.search 'query={"title":"
|
||||
- `tool list` 用于动态发现当前服务可调用的工具
|
||||
- `tool show` 会输出参数名、类型和描述
|
||||
- `tool run` 参数格式固定为 `key=value`;对象、数组、布尔值和 `null` 使用合法 JSON
|
||||
- 工具命令自动解析本地 REST API 的 `success/message/data` 响应信封;`tool run` 输出
|
||||
`data.result` 中的 JSON 或普通文本,返回内容为空时显示成功提示。
|
||||
- REST 请求失败时显示服务端错误原因并返回非零退出码,不输出整个工具目录响应。
|
||||
- MoviePilot 业务能力统一通过 `moviepilot_api` 的固定 `operation_id` 调用;不接受任意 URL、method、认证头或 Token,也不兼容旧业务工具名
|
||||
- 涉及精确媒体身份的 operation 统一使用 `media_source` + `media_id`,两个字段必须成对传递并复用搜索结果
|
||||
- `read_skill`、`read_file`、`write_file`、`edit_file` 和 `execute_command`
|
||||
@@ -626,3 +629,7 @@ moviepilot scheduler list
|
||||
```shell
|
||||
moviepilot scheduler run subscribe_refresh
|
||||
```
|
||||
|
||||
`scheduler list/run` 通过工具网关解析 REST 信封和内部业务响应。列表显示任务 ID、状态、
|
||||
下次运行时间和名称;执行命令显示业务返回值,无返回内容时显示调用成功提示,业务失败时
|
||||
显示原因并返回非零退出码。
|
||||
|
||||
@@ -106,7 +106,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
|
||||
| Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 |
|
||||
| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 |
|
||||
| 全量 mypy 历史债务 | 9,174 / 508 文件 | 系统版本查询补齐类型后的现状基线;消息媒体交互类型债务已清零,canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
|
||||
| 全量 mypy 历史债务 | 9,172 / 508 文件 | CLI 工具响应解包补齐数据类型校验后的现状基线;消息媒体交互类型债务已清零,canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
|
||||
| Ruff 历史诊断 | 100 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ Runtime Commands:
|
||||
moviepilot start|stop|restart|status|logs|version
|
||||
moviepilot doctor [--json] [--fix] [--deep]
|
||||
moviepilot config ...
|
||||
moviepilot tool ...
|
||||
moviepilot scheduler ...
|
||||
moviepilot tool list|show|run ...
|
||||
moviepilot scheduler list|run ...
|
||||
|
||||
Discovery Commands:
|
||||
moviepilot help
|
||||
|
||||
@@ -1063,7 +1063,7 @@
|
||||
"app/cli.py": {
|
||||
"attr-defined": 2,
|
||||
"misc": 57,
|
||||
"no-any-return": 6,
|
||||
"no-any-return": 4,
|
||||
"type-arg": 3,
|
||||
"union-attr": 2
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""本地 CLI 统一 Agent 工具目录测试。"""
|
||||
|
||||
from unittest.mock import patch
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from click import ClickException
|
||||
@@ -9,6 +11,73 @@ from click.testing import CliRunner
|
||||
from app.cli import _parse_key_value_pairs, cli
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_request() -> Iterator[Mock]:
|
||||
"""只替换 HTTP 边界,保留工具信封解析与命令渲染。"""
|
||||
with (
|
||||
patch("app.cli._backend_runtime", return_value={}),
|
||||
patch("app.cli._runtime_api_token", return_value="cli-test-token"),
|
||||
patch("app.cli._http_request") as request,
|
||||
):
|
||||
yield request
|
||||
|
||||
|
||||
def _http_response(data: object, status: int = 200) -> dict:
|
||||
"""构造与 MCP REST 端点一致的统一成功信封。"""
|
||||
payload = {"success": True, "message": "", "data": data}
|
||||
return {"status": status, "json": payload, "text": json.dumps(payload)}
|
||||
|
||||
|
||||
def test_tool_list_unwraps_rest_envelope(http_request: Mock) -> None:
|
||||
"""工具列表应解包后按名称排序,不能把整个成功信封当错误。"""
|
||||
http_request.return_value = _http_response([{"name": "read_skill"}, {"name": "moviepilot_api"}])
|
||||
|
||||
result = CliRunner().invoke(cli, ["tool", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.output == "moviepilot_api\nread_skill\n"
|
||||
|
||||
|
||||
def test_tool_show_unwraps_rest_envelope(http_request: Mock) -> None:
|
||||
"""工具详情必须展示 data 内的名称、描述及参数。"""
|
||||
http_request.return_value = _http_response({
|
||||
"name": "moviepilot_api",
|
||||
"description": "统一 API 网关",
|
||||
"inputSchema": {
|
||||
"properties": {"operation_id": {"type": "string", "description": "操作 ID"}},
|
||||
"required": ["operation_id"],
|
||||
},
|
||||
})
|
||||
|
||||
result = CliRunner().invoke(cli, ["tool", "show", "moviepilot_api"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Command: moviepilot_api" in result.output
|
||||
assert "Description: 统一 API 网关" in result.output
|
||||
assert "operation_id*" in result.output
|
||||
assert "string" in result.output
|
||||
assert "操作 ID" in result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool_result", ['{"version":"v3.1.3"}', '[1,2]', "执行完成", "false", "null", ""])
|
||||
def test_tool_run_unwraps_and_prints_result(http_request: Mock, tool_result: str) -> None:
|
||||
"""执行结果保留 JSON 或文本语义,空结果也必须有明确回显。"""
|
||||
http_request.return_value = _http_response({"result": tool_result}, status=201)
|
||||
|
||||
result = CliRunner().invoke(cli, ["tool", "run", "moviepilot_api", "operation_id=system.versions"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.output.strip()
|
||||
if tool_result.startswith(("{", "[")):
|
||||
assert json.loads(result.output) == json.loads(tool_result)
|
||||
elif tool_result == "false":
|
||||
assert result.output == "False\n"
|
||||
elif tool_result in {"null", ""}:
|
||||
assert result.output == "工具执行成功(无返回内容)\n"
|
||||
else:
|
||||
assert result.output == f"{tool_result}\n"
|
||||
|
||||
|
||||
def test_tool_arguments_parse_structured_json() -> None:
|
||||
"""tool run 应支持 API 网关的对象、数组和布尔参数。"""
|
||||
assert _parse_key_value_pairs(
|
||||
@@ -32,56 +101,140 @@ def test_tool_arguments_reject_invalid_structured_json() -> None:
|
||||
_parse_key_value_pairs(("query={broken}",))
|
||||
|
||||
|
||||
def test_scheduler_cli_uses_final_api_gateway_catalog() -> None:
|
||||
def test_scheduler_cli_uses_final_api_gateway_catalog(http_request: Mock) -> None:
|
||||
"""scheduler 子命令不得继续调用已删除的旧工具名。"""
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("app.cli._backend_runtime", return_value={}),
|
||||
patch(
|
||||
"app.cli._call_tool",
|
||||
return_value={
|
||||
"success": True,
|
||||
"message": "",
|
||||
"data": [
|
||||
{
|
||||
"id": "job-1",
|
||||
"status": "waiting",
|
||||
"next_run": None,
|
||||
"name": "测试任务",
|
||||
}
|
||||
],
|
||||
},
|
||||
) as call_tool,
|
||||
):
|
||||
result = runner.invoke(cli, ["scheduler", "list"])
|
||||
http_request.return_value = _http_response({"result": json.dumps({
|
||||
"success": True,
|
||||
"message": "",
|
||||
"data": [{"id": "job-1", "status": "waiting", "next_run": None, "name": "测试任务"}],
|
||||
})})
|
||||
result = runner.invoke(cli, ["scheduler", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "job-1" in result.output
|
||||
call_tool.assert_called_once_with(
|
||||
"moviepilot_api",
|
||||
{"operation_id": "scheduler.list"},
|
||||
assert result.output == "job-1\twaiting\tNone\t测试任务\n"
|
||||
http_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/api/v1/mcp/tools/call",
|
||||
json_body={"tool_name": "moviepilot_api", "arguments": {"operation_id": "scheduler.list"}},
|
||||
headers={"X-API-KEY": "cli-test-token"},
|
||||
timeout=30.0,
|
||||
runtime={},
|
||||
)
|
||||
|
||||
|
||||
def test_scheduler_run_uses_structured_operation() -> None:
|
||||
def test_scheduler_run_uses_structured_operation(http_request: Mock) -> None:
|
||||
"""scheduler run 应把 job_id 放入固定 operation 的 query。"""
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("app.cli._backend_runtime", return_value={}),
|
||||
patch(
|
||||
"app.cli._call_tool",
|
||||
return_value={"success": True, "message": "", "data": True},
|
||||
) as call_tool,
|
||||
):
|
||||
result = runner.invoke(cli, ["scheduler", "run", "job-1"])
|
||||
http_request.return_value = _http_response({
|
||||
"result": json.dumps({"success": True, "message": "", "data": True})
|
||||
})
|
||||
result = runner.invoke(cli, ["scheduler", "run", "job-1"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
call_tool.assert_called_once_with(
|
||||
"moviepilot_api",
|
||||
{
|
||||
"operation_id": "scheduler.run",
|
||||
"query": {"job_id": "job-1"},
|
||||
assert result.output == "True\n"
|
||||
http_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/api/v1/mcp/tools/call",
|
||||
json_body={
|
||||
"tool_name": "moviepilot_api",
|
||||
"arguments": {"operation_id": "scheduler.run", "query": {"job_id": "job-1"}},
|
||||
},
|
||||
headers={"X-API-KEY": "cli-test-token"},
|
||||
timeout=30.0,
|
||||
runtime={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", [
|
||||
["tool", "list"],
|
||||
["tool", "show", "moviepilot_api"],
|
||||
["tool", "run", "moviepilot_api"],
|
||||
["scheduler", "list"],
|
||||
["scheduler", "run", "job-1"],
|
||||
])
|
||||
@pytest.mark.parametrize("status, payload, expected", [
|
||||
(200, {"success": False, "message": "工具不可用", "data": None}, "工具不可用"),
|
||||
(401, {"success": False, "message": "认证失败", "data": None}, "认证失败"),
|
||||
(500, {"detail": "服务异常"}, "服务异常"),
|
||||
(502, None, "响应格式错误"),
|
||||
(200, {"data": ["huge directory"]}, "HTTP 200"),
|
||||
])
|
||||
def test_tool_commands_report_http_failures(
|
||||
http_request: Mock, command: list[str], status: int, payload: object, expected: str,
|
||||
) -> None:
|
||||
"""传输或信封错误必须明确失败,且不得倾倒完整响应正文。"""
|
||||
http_request.return_value = {"status": status, "json": payload, "text": "huge response" * 40000}
|
||||
|
||||
result = CliRunner().invoke(cli, command)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert expected in result.output
|
||||
assert "huge response" not in result.output
|
||||
assert len(result.output) < 200
|
||||
http_request.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command, data, expected", [
|
||||
(["tool", "list"], {}, "不是工具列表"),
|
||||
(["tool", "show", "moviepilot_api"], [], "不是工具对象"),
|
||||
(["tool", "run", "moviepilot_api"], {}, "data.result"),
|
||||
(["tool", "run", "moviepilot_api"], {"result": None}, "data.result"),
|
||||
])
|
||||
def test_tool_commands_reject_invalid_data(
|
||||
http_request: Mock, command: list[str], data: object, expected: str,
|
||||
) -> None:
|
||||
"""损坏的成功信封不得变成静默输出或错误的成功回显。"""
|
||||
http_request.return_value = _http_response(data)
|
||||
|
||||
result = CliRunner().invoke(cli, command)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert expected in result.output
|
||||
|
||||
|
||||
def test_tool_show_preserves_missing_tool_error(http_request: Mock) -> None:
|
||||
"""不存在的工具仍使用 CLI 原有的明确错误。"""
|
||||
http_request.return_value = {"status": 404, "json": {}, "text": ""}
|
||||
|
||||
result = CliRunner().invoke(cli, ["tool", "show", "missing"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "工具不存在:missing" in result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", [["scheduler", "list"], ["scheduler", "run", "job-1"]])
|
||||
def test_scheduler_reports_inner_business_failure(http_request: Mock, command: list[str]) -> None:
|
||||
"""REST 调用成功不掩盖网关返回的业务失败。"""
|
||||
http_request.return_value = _http_response({
|
||||
"result": json.dumps({"success": False, "message": "任务不存在", "data": None}),
|
||||
})
|
||||
|
||||
result = CliRunner().invoke(cli, command)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "任务不存在" in result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("data", [None, ""])
|
||||
def test_scheduler_run_reports_empty_success(http_request: Mock, data: object) -> None:
|
||||
"""无业务返回值时仍显示调度调用成功,并且只调用一次。"""
|
||||
http_request.return_value = _http_response({
|
||||
"result": json.dumps({"success": True, "message": "", "data": data}),
|
||||
})
|
||||
|
||||
result = CliRunner().invoke(cli, ["scheduler", "run", "job-1"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.output == "调度任务调用成功(无返回内容)\n"
|
||||
http_request.assert_called_once()
|
||||
|
||||
|
||||
def test_tool_list_accepts_empty_catalog(http_request: Mock) -> None:
|
||||
"""合法空工具目录不应误判为请求失败。"""
|
||||
http_request.return_value = _http_response([])
|
||||
|
||||
result = CliRunner().invoke(cli, ["tool", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.output == ""
|
||||
|
||||
Reference in New Issue
Block a user