Files
KiloStar/tests/unit/test_utils_mcp_helper.py
T
zhaoxi b15eeb9e74 fix(toolset): 工具传递改为展开的 tools 列表,不再用 FunctionToolset 包装
前端/DB 仍用 toolset 做逻辑分组管理,但传给 pydantic-ai Agent 时
把 toolset 内的 callable 展开为 tools=[] 扁平列表,MCP server 等
需要 toolset 语义的单独走 toolsets=[] 参数。解决工具"存在但调不了"的问题。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 19:05:59 +00:00

246 lines
8.0 KiB
Python

"""``utils.mcp_helper`` 的纯逻辑路径:构建 toolset、合并、列出工具。
由于 stdio MCP 实例化会立即解析 command/args,但不会主动启动子进程(只在 ``async with``
进入后才连),因此 ``build_mcp_toolsets`` 可以在测试里直接验证。
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from kilostar.utils import mcp_helper
# ─── build_mcp_toolsets ──────────────────────────────────────────────────────
def test_build_mcp_toolsets_when_mcp_unavailable_returns_empty(monkeypatch):
monkeypatch.setattr(mcp_helper, "_MCP_AVAILABLE", False)
assert mcp_helper.build_mcp_toolsets({"a": {"transport": "stdio"}}) == []
def test_build_mcp_toolsets_skips_unsupported_transport():
configs = {"weird": {"transport": "ftp", "name": "weird"}}
assert mcp_helper.build_mcp_toolsets(configs) == []
def test_build_mcp_toolsets_creates_one_per_supported_config():
configs = {
"fs": {
"name": "fs",
"transport": "stdio",
"command": "echo",
"args": ["hi"],
"tool_prefix": "fs",
},
"remote_sse": {
"name": "remote_sse",
"transport": "sse",
"url": "http://localhost:9000/sse",
},
"remote_http": {
"name": "remote_http",
"transport": "http",
"url": "http://localhost:9001",
},
}
toolsets = mcp_helper.build_mcp_toolsets(configs)
assert len(toolsets) == 3
ids = {getattr(t, "id", None) for t in toolsets}
assert ids == {"fs", "remote_sse", "remote_http"}
# ─── get_mcp_toolsets_from_gsm ───────────────────────────────────────────────
async def test_get_mcp_toolsets_from_gsm_returns_empty_when_unavailable(
monkeypatch,
):
monkeypatch.setattr(mcp_helper, "_MCP_AVAILABLE", False)
assert await mcp_helper.get_mcp_toolsets_from_gsm() == []
async def test_get_mcp_toolsets_from_gsm_swallows_errors_with_no_actor(monkeypatch):
"""没有注册 actor 时返回空列表而非抛出。"""
monkeypatch.setattr(mcp_helper, "_MCP_AVAILABLE", True)
async def boom(*_a, **_kw):
raise ValueError("no actor")
from kilostar.core.global_state_machine import gsm_snapshot as snap_mod
monkeypatch.setattr(snap_mod, "fetch_snapshot", boom)
assert await mcp_helper.get_mcp_toolsets_from_gsm() == []
async def test_get_mcp_toolsets_from_gsm_uses_configs_via_snapshot(monkeypatch):
"""注入一个返回 mcp 配置的 snapshot,验证最终走通到 build_mcp_toolsets。"""
monkeypatch.setattr(mcp_helper, "_MCP_AVAILABLE", True)
from kilostar.core.global_state_machine import gsm_snapshot as snap_mod
from kilostar.core.global_state_machine.gsm_snapshot import GSMSnapshot
snap = GSMSnapshot(
version=1,
mcp_servers={
"fs": {
"name": "fs",
"transport": "stdio",
"command": "echo",
"args": ["hi"],
},
},
)
async def _fake_fetch(**_kw):
return snap
monkeypatch.setattr(snap_mod, "fetch_snapshot", _fake_fetch)
toolsets = await mcp_helper.get_mcp_toolsets_from_gsm()
assert len(toolsets) == 1
assert getattr(toolsets[0], "id", None) == "fs"
# ─── get_all_tools_and_toolsets_for_scope ──────────────────────────────────────────────
async def test_get_all_tools_and_toolsets_for_scope_merges_local_and_mcp(monkeypatch):
"""本地 tools 列表和 mcp toolsets 列表都应该被正确返回。"""
monkeypatch.setattr(mcp_helper, "_MCP_AVAILABLE", True)
local_a = MagicMock(name="local-system")
local_b = MagicMock(name="local-personal")
from kilostar.core.global_state_machine import gsm_snapshot as snap_mod
from kilostar.core.global_state_machine.gsm_snapshot import GSMSnapshot
snap = GSMSnapshot(version=1, mcp_servers={})
async def _fake_fetch(**_kw):
return snap
monkeypatch.setattr(snap_mod, "fetch_snapshot", _fake_fetch)
monkeypatch.setattr(
snap_mod,
"build_tools_for_scope",
lambda s, scope, **kw: [local_a, local_b],
)
tools, toolsets = await mcp_helper.get_all_tools_and_toolsets_for_scope("control_node")
assert tools == [local_a, local_b]
assert toolsets == []
async def test_get_all_tools_and_toolsets_local_failure_does_not_block_mcp(
monkeypatch,
):
"""本地 tools 拉取失败时仍然要返回 mcp toolsets。"""
monkeypatch.setattr(mcp_helper, "_MCP_AVAILABLE", True)
from kilostar.core.global_state_machine import gsm_snapshot as snap_mod
from kilostar.core.global_state_machine.gsm_snapshot import GSMSnapshot
snap = GSMSnapshot(
version=1,
mcp_servers={
"fs": {
"name": "fs",
"transport": "stdio",
"command": "echo",
"args": ["hi"],
}
},
)
async def _fake_fetch(**_kw):
return snap
def _broken_build(*_a, **_kw):
raise RuntimeError("boom")
monkeypatch.setattr(snap_mod, "fetch_snapshot", _fake_fetch)
monkeypatch.setattr(snap_mod, "build_tools_for_scope", _broken_build)
tools, toolsets = await mcp_helper.get_all_tools_and_toolsets_for_scope("control_node")
assert tools == []
assert len(toolsets) == 1
assert getattr(toolsets[0], "id", None) == "fs"
# ─── list_mcp_tools_for_configs ──────────────────────────────────────────────
async def test_list_mcp_tools_for_configs_returns_tool_names(monkeypatch):
"""模拟 server.get_tools() 返回若干带 name 的工具,结果应被抽取出来。"""
monkeypatch.setattr(mcp_helper, "_MCP_AVAILABLE", True)
class _FakeTool:
def __init__(self, name: str) -> None:
self.name = name
class _FakeServer:
def __init__(self, server_id: str, tools):
self.id = server_id
self._tools = tools
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get_tools(self):
return self._tools
fake_servers = [_FakeServer("fs", [_FakeTool("read"), _FakeTool("write")])]
monkeypatch.setattr(
mcp_helper, "build_mcp_toolsets", lambda configs: list(fake_servers)
)
result = await mcp_helper.list_mcp_tools_for_configs(
{"fs": {"name": "fs", "transport": "stdio"}}
)
assert len(result) == 1
assert result[0]["server_id"] == "fs"
assert result[0]["tools"] == ["read", "write"]
assert "error" not in result[0]
async def test_list_mcp_tools_for_configs_records_error_on_failure(monkeypatch):
monkeypatch.setattr(mcp_helper, "_MCP_AVAILABLE", True)
class _BrokenServer:
def __init__(self) -> None:
self.id = "broken"
async def __aenter__(self):
raise RuntimeError("connect fail")
async def __aexit__(self, exc_type, exc, tb):
return False
async def get_tools(self): # pragma: no cover - 不会被调用
return []
monkeypatch.setattr(
mcp_helper, "build_mcp_toolsets", lambda configs: [_BrokenServer()]
)
result = await mcp_helper.list_mcp_tools_for_configs(
{"broken": {"name": "broken", "transport": "stdio"}}
)
assert len(result) == 1
assert result[0]["server_id"] == "broken"
assert result[0]["tools"] == []
assert "connect fail" in result[0]["error"]
async def test_list_mcp_tools_for_configs_when_mcp_unavailable(monkeypatch):
monkeypatch.setattr(mcp_helper, "_MCP_AVAILABLE", False)
assert await mcp_helper.list_mcp_tools_for_configs({"x": {}}) == []