mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 02:05:13 +08:00
266 lines
8.4 KiB
Python
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
|