Files
MoviePilot/tests/test_agent_mcp.py

266 lines
8.4 KiB
Python

import sys
import textwrap
from unittest.mock import AsyncMock, patch
import pytest
from app.agent.mcp import AgentMcpManager, AgentMcpToolSpec
from app.agent.tools.catalog import ToolCatalogSnapshot
from app.agent.tools.impl.mcp import (
McpExternalTool,
create_external_mcp_tools,
select_legacy_mcp_tools,
)
from app.schemas.agent import AgentMcpServerConfig
def _write_stdio_mcp_server(tmp_path):
"""写入一个用于测试的最小 stdio MCP 服务。"""
server_path = tmp_path / "stdio_mcp_server.py"
server_path.write_text(
textwrap.dedent(
"""
import json
import sys
TOOLS = [
{
"name": "echo",
"description": "Echo input text.",
"inputSchema": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Text to echo"}
},
"required": ["text"],
},
}
]
for line in sys.stdin:
request = json.loads(line)
request_id = request.get("id")
method = request.get("method")
if request_id is None:
continue
if method == "initialize":
result = {
"protocolVersion": request["params"]["protocolVersion"],
"capabilities": {"tools": {}},
"serverInfo": {"name": "Fake MCP", "version": "1.0.0"},
}
elif method == "tools/list":
result = {"tools": TOOLS}
elif method == "tools/call":
args = request.get("params", {}).get("arguments", {})
result = {"content": [{"type": "text", "text": args.get("text", "")}]}
else:
result = {}
print(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}), flush=True)
"""
),
encoding="utf-8",
)
return server_path
@pytest.mark.anyio
async def test_stdio_mcp_server_lists_tools(tmp_path):
"""stdio MCP 服务器应能被初始化并读取工具列表。"""
server_path = _write_stdio_mcp_server(tmp_path)
manager = AgentMcpManager()
server = AgentMcpServerConfig(
id="fake",
name="Fake MCP",
transport="stdio",
command=sys.executable,
args=[str(server_path)],
timeout=5,
)
tools = await manager.list_server_tools(server)
assert len(tools) == 1
assert tools[0].name == "echo"
assert tools[0].agent_tool_name == "mcp_fake_mcp_echo"
assert tools[0].input_schema["properties"]["text"]["type"] == "string"
@pytest.mark.anyio
async def test_enabled_specs_preserve_cross_server_name_collisions() -> None:
"""跨 MCP 服务器生成相同 Agent 名时必须把全部身份交给目录判断。"""
manager = AgentMcpManager()
first = AgentMcpServerConfig(id="one", name="one", transport="stdio", command="one")
second = AgentMcpServerConfig(id="two", name="two", transport="stdio", command="two")
def _spec(server):
return AgentMcpToolSpec(
server=server,
name="echo",
agent_tool_name="shared_echo",
description="echo",
input_schema={"type": "object", "properties": {}},
)
with patch.object(manager, "get_servers", return_value=[first, second]), patch.object(
manager,
"list_server_tools",
new=AsyncMock(side_effect=lambda server: [_spec(server)]),
):
specs = await manager.list_enabled_tool_specs()
assert [spec.server.id for spec in specs] == ["one", "two"]
@pytest.mark.anyio
async def test_mcp_catalog_uses_server_id_and_legacy_execution_keeps_first() -> None:
"""目录应区分同显示名服务器,普通执行仍保留首个同名工具。"""
first_server = AgentMcpServerConfig(
id="one",
name="Shared Name",
transport="stdio",
command="one",
)
second_server = AgentMcpServerConfig(
id="two",
name="Shared Name",
transport="stdio",
command="two",
)
def _spec(server):
return AgentMcpToolSpec(
server=server,
name="echo",
agent_tool_name="shared_echo",
description="echo",
input_schema={"type": "object", "properties": {}},
)
tools = await create_external_mcp_tools(
session_id="session",
user_id="user",
specs=[_spec(first_server), _spec(second_server)],
)
catalog = ToolCatalogSnapshot.from_tools(
tools,
plugin_revision=0,
factory_revision="factory-v1",
)
assert [entry.source for entry in catalog.collisions["shared_echo"]] == [
"mcp:one",
"mcp:two",
]
assert len({entry.identity for entry in catalog.collisions["shared_echo"]}) == 2
assert select_legacy_mcp_tools(tools) == [tools[0]]
@pytest.mark.anyio
async def test_mcp_catalog_distinguishes_normalized_names_within_server() -> None:
"""同一服务内规范化重名的原始工具仍应具有不同绑定身份。"""
server = AgentMcpServerConfig(
id="shared",
name="Shared",
transport="stdio",
command="shared",
)
def _spec(name: str) -> AgentMcpToolSpec:
return AgentMcpToolSpec(
server=server,
name=name,
agent_tool_name="mcp_shared_foo_bar",
description="same description",
input_schema={"type": "object", "properties": {}},
)
tools = await create_external_mcp_tools(
session_id="session",
user_id="user",
specs=[_spec("foo-bar"), _spec("foo_bar")],
)
catalog = ToolCatalogSnapshot.from_tools(
tools,
plugin_revision=0,
factory_revision="factory-v1",
)
first_only = ToolCatalogSnapshot.from_tools(
[tools[0]],
plugin_revision=0,
factory_revision="factory-v1",
)
second_only = ToolCatalogSnapshot.from_tools(
[tools[1]],
plugin_revision=0,
factory_revision="factory-v1",
)
collisions = catalog.collisions["mcp_shared_foo_bar"]
assert len({entry.identity for entry in collisions}) == 2
assert len({entry.revision.implementation for entry in collisions}) == 2
assert first_only.signature != second_only.signature
@pytest.mark.anyio
async def test_stdio_mcp_server_calls_tool(tmp_path):
"""stdio MCP 工具应能通过 tools/call 返回内容。"""
server_path = _write_stdio_mcp_server(tmp_path)
manager = AgentMcpManager()
server = AgentMcpServerConfig(
id="fake",
name="Fake MCP",
transport="stdio",
command=sys.executable,
args=[str(server_path)],
timeout=5,
)
result = await manager.call_server_tool(server, "echo", {"text": "hello"})
assert result == {"content": [{"type": "text", "text": "hello"}]}
def test_normalize_server_generates_runtime_defaults():
"""MCP 配置规范化应补齐默认值并清理空字段。"""
manager = AgentMcpManager()
server = manager.normalize_server(
{
"id": "demo",
"name": "Demo",
"transport": "http",
"url": " https://example.com/mcp ",
"headers": {" Authorization ": "Bearer token", "": "ignored"},
"timeout": "bad",
}
)
assert server.id == "demo"
assert server.url == "https://example.com/mcp"
assert server.headers == {"Authorization": "Bearer token"}
assert server.timeout == 30
assert server.require_admin is True
def test_mcp_external_tool_uses_discovered_schema():
"""外部 MCP 工具应保留发现到的 JSON Schema。"""
server = AgentMcpServerConfig(id="fake", name="Fake MCP")
spec = AgentMcpToolSpec(
server=server,
name="echo",
agent_tool_name="mcp_fake_echo",
description="Echo input text.",
input_schema={
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
)
tool = McpExternalTool(spec=spec, session_id="session-1", user_id="10001")
assert tool.name == "mcp_fake_echo"
assert tool.args_schema["properties"]["text"]["type"] == "string"
assert tool.require_admin is True