"""``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_toolsets_for_scope ────────────────────────────────────────────── async def test_get_all_toolsets_for_scope_merges_local_and_mcp(monkeypatch): """本地 toolset 列表和 mcp toolset 列表都应该被拼接。""" 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_toolsets_for_scope", lambda s, scope: [local_a, local_b], ) result = await mcp_helper.get_all_toolsets_for_scope("control_node") assert result == [local_a, local_b] async def test_get_all_toolsets_for_scope_local_failure_does_not_block_mcp( monkeypatch, ): """本地 toolset 拉取失败时仍然要返回 mcp toolset。""" 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 # local 路径:fetch 成功但 build_toolsets_for_scope 抛错 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_toolsets_for_scope", _broken_build) result = await mcp_helper.get_all_toolsets_for_scope("control_node") assert len(result) == 1 assert getattr(result[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": {}}) == []