""" MCPToolRegistry 測試 ==================== ADR-081: Phase 1 動態工具登記冊 測試項目: - SensorDimension 枚舉完整性 - RegisteredTool dataclass - register_provider() — 工具登記 / 重複登記防護 / 停用 Provider - register_tool_manually() — 測試用手動注入 - suggest_tools() — alertname 過濾 / priority 排序 / max_tools 限制 - _classify_tool() — 工具名稱 → 感官維度自動推斷 2026-04-15 Claude Sonnet 4.6 + ogt: Phase 1 初始建立 """ from __future__ import annotations import pytest from src.plugins.mcp.interfaces import MCPTool, MCPToolProvider, MCPToolResult from src.services.mcp_tool_registry import ( MCPToolRegistry, SensorDimension, _classify_tool, ) # ───────────────────────────────────────────────────────────────────────────── # Stubs # ───────────────────────────────────────────────────────────────────────────── def _make_tool(name: str, server: str = "test") -> MCPTool: return MCPTool(name=name, description="test tool", input_schema={}, server_name=server) class _StubProvider(MCPToolProvider): """最小可用 Provider stub(無外部依賴)""" def __init__(self, name: str, tools: list[str], enabled: bool = True) -> None: self._name = name self._tools = [_make_tool(t, name) for t in tools] self._enabled = enabled @property def name(self) -> str: return self._name @property def enabled(self) -> bool: return self._enabled async def list_tools(self) -> list[MCPTool]: return self._tools async def execute(self, tool_name: str, parameters: dict) -> MCPToolResult: return MCPToolResult(success=True, execution_id="stub", output={}) # ───────────────────────────────────────────────────────────────────────────── # SensorDimension # ───────────────────────────────────────────────────────────────────────────── class TestSensorDimension: """8D 感官維度枚舉必須完整""" def test_all_8_dimensions_exist(self): dims = {d.value for d in SensorDimension} expected = { "d1_k8s_state", "d2_logs", "d3_metrics", "d4_changes", "d5_business", "d6_history", "d7_peers", "d8_topology", } assert dims == expected def test_is_str_enum(self): assert isinstance(SensorDimension.D1_K8S_STATE, str) assert SensorDimension.D1_K8S_STATE == "d1_k8s_state" # ───────────────────────────────────────────────────────────────────────────── # _classify_tool — 自動維度推斷 # ───────────────────────────────────────────────────────────────────────────── class TestClassifyTool: """工具名稱 → 感官維度推斷規則""" def _provider(self) -> _StubProvider: return _StubProvider("p", []) def test_pod_describe_is_d1(self): reg = _classify_tool(_make_tool("kubectl_describe_pod"), self._provider()) assert SensorDimension.D1_K8S_STATE in reg.dimensions assert reg.priority == 2 def test_event_is_d1(self): reg = _classify_tool(_make_tool("kubectl_get_events"), self._provider()) assert SensorDimension.D1_K8S_STATE in reg.dimensions def test_logs_is_d2(self): reg = _classify_tool(_make_tool("kubectl_logs"), self._provider()) assert SensorDimension.D2_LOGS in reg.dimensions assert reg.priority == 2 def test_metrics_is_d3(self): reg = _classify_tool(_make_tool("prometheus_query"), self._provider()) assert SensorDimension.D3_METRICS in reg.dimensions assert reg.priority == 3 def test_deploy_diff_is_d4(self): reg = _classify_tool(_make_tool("git_diff"), self._provider()) assert SensorDimension.D4_CHANGES in reg.dimensions def test_sli_is_d5(self): reg = _classify_tool(_make_tool("grafana_sli"), self._provider()) assert SensorDimension.D5_BUSINESS in reg.dimensions def test_knowledge_is_d6(self): reg = _classify_tool(_make_tool("rag_knowledge_search"), self._provider()) assert SensorDimension.D6_HISTORY in reg.dimensions def test_peer_is_d7(self): reg = _classify_tool(_make_tool("peer_health_check"), self._provider()) assert SensorDimension.D7_PEERS in reg.dimensions def test_topology_is_d8(self): reg = _classify_tool(_make_tool("istio_topology"), self._provider()) assert SensorDimension.D8_TOPOLOGY in reg.dimensions def test_ssh_covers_d1_d2_d3(self): reg = _classify_tool(_make_tool("ssh_exec"), self._provider()) assert SensorDimension.D1_K8S_STATE in reg.dimensions assert SensorDimension.D2_LOGS in reg.dimensions assert SensorDimension.D3_METRICS in reg.dimensions def test_unknown_tool_defaults_to_d1(self): reg = _classify_tool(_make_tool("some_unknown_tool"), self._provider()) assert SensorDimension.D1_K8S_STATE in reg.dimensions def test_kube_type_hints_for_d1(self): reg = _classify_tool(_make_tool("kubectl_describe"), self._provider()) assert "Kube" in reg.incident_type_hints or any( h in reg.incident_type_hints for h in ["Pod", "Deploy", "Node"] ) def test_rollout_tool_is_high_priority_d1(self): reg = _classify_tool(_make_tool("k8s_watch_rollout"), self._provider()) assert SensorDimension.D1_K8S_STATE in reg.dimensions assert reg.priority == 1 assert "Deploy" in reg.incident_type_hints def test_ssh_type_hints(self): reg = _classify_tool(_make_tool("ssh_run"), self._provider()) assert any(h in reg.incident_type_hints for h in ["Host", "Docker"]) # ───────────────────────────────────────────────────────────────────────────── # MCPToolRegistry — register_provider # ───────────────────────────────────────────────────────────────────────────── class TestRegisterProvider: """Provider 登記行為""" @pytest.mark.asyncio async def test_register_adds_tools(self): registry = MCPToolRegistry() provider = _StubProvider("k8s", ["kubectl_describe", "kubectl_logs"]) count = await registry.register_provider(provider) assert count == 2 assert registry.tool_count == 2 assert registry.provider_count == 1 @pytest.mark.asyncio async def test_duplicate_provider_skipped(self): registry = MCPToolRegistry() provider = _StubProvider("k8s", ["kubectl_get"]) await registry.register_provider(provider) # 再次登記同一 Provider count2 = await registry.register_provider(provider) assert count2 == 0 assert registry.tool_count == 1 # 不重複 @pytest.mark.asyncio async def test_disabled_provider_skipped(self): registry = MCPToolRegistry() disabled = _StubProvider("ssh", ["ssh_exec"], enabled=False) count = await registry.register_provider(disabled) assert count == 0 assert registry.tool_count == 0 @pytest.mark.asyncio async def test_multiple_providers_accumulate(self): registry = MCPToolRegistry() p1 = _StubProvider("k8s", ["kubectl_describe"]) p2 = _StubProvider("ssh", ["ssh_exec"]) await registry.register_provider(p1) await registry.register_provider(p2) assert registry.provider_count == 2 assert registry.tool_count == 2 # ───────────────────────────────────────────────────────────────────────────── # MCPToolRegistry — register_tool_manually # ───────────────────────────────────────────────────────────────────────────── class TestRegisterToolManually: """手動工具注入(測試用)""" def test_manual_register_adds_tool(self): registry = MCPToolRegistry() provider = _StubProvider("test", []) tool = _make_tool("custom_tool") registry.register_tool_manually( tool=tool, provider=provider, dimensions=[SensorDimension.D3_METRICS], priority=3, ) assert registry.tool_count == 1 all_tools = registry.get_all_tools() assert all_tools[0].tool.name == "custom_tool" assert SensorDimension.D3_METRICS in all_tools[0].dimensions def test_manual_register_default_priority(self): registry = MCPToolRegistry() provider = _StubProvider("test", []) registry.register_tool_manually( tool=_make_tool("t"), provider=provider, dimensions=[SensorDimension.D1_K8S_STATE], ) assert registry.get_all_tools()[0].priority == 5 # ───────────────────────────────────────────────────────────────────────────── # MCPToolRegistry — suggest_tools # ───────────────────────────────────────────────────────────────────────────── class TestSuggestTools: """動態工具推薦邏輯""" def _registry_with_tools(self) -> MCPToolRegistry: registry = MCPToolRegistry() provider = _StubProvider("test", []) # K8s 工具(僅適用 Kube* 告警) k8s_tool = _make_tool("kubectl_describe") registry.register_tool_manually( tool=k8s_tool, provider=provider, dimensions=[SensorDimension.D1_K8S_STATE], incident_type_hints=["Kube", "Pod"], priority=2, ) # 通用工具(所有告警適用) generic_tool = _make_tool("prometheus_query") registry.register_tool_manually( tool=generic_tool, provider=provider, dimensions=[SensorDimension.D3_METRICS], incident_type_hints=[], priority=3, ) return registry def test_kube_alertname_gets_k8s_tool(self): registry = self._registry_with_tools() tools = registry.suggest_tools(alertname="KubePodCrashLooping") names = [t.tool.name for t in tools] assert "kubectl_describe" in names def test_non_kube_alertname_skips_k8s_tool(self): registry = self._registry_with_tools() tools = registry.suggest_tools(alertname="HostDiskUsageHigh") names = [t.tool.name for t in tools] assert "kubectl_describe" not in names assert "prometheus_query" in names def test_custom_alert_with_deployment_label_gets_k8s_tool(self): registry = self._registry_with_tools() tools = registry.suggest_tools( alertname="AwoooPT16Canary", incident_labels={"deployment": "awoooi-auto-repair-canary", "namespace": "awoooi-prod"}, ) names = [t.tool.name for t in tools] assert "kubectl_describe" in names assert "prometheus_query" in names def test_namespace_only_non_kube_alert_does_not_get_k8s_tool(self): registry = self._registry_with_tools() tools = registry.suggest_tools( alertname="HostErrorLogFlood", incident_labels={"namespace": "infra"}, ) names = [t.tool.name for t in tools] assert "kubectl_describe" not in names assert "prometheus_query" in names def test_empty_alertname_gets_generic_only(self): registry = self._registry_with_tools() tools = registry.suggest_tools(alertname="") names = [t.tool.name for t in tools] assert "prometheus_query" in names assert "kubectl_describe" not in names def test_max_tools_limit(self): registry = MCPToolRegistry() provider = _StubProvider("test", []) for i in range(10): registry.register_tool_manually( tool=_make_tool(f"tool_{i}"), provider=provider, dimensions=[SensorDimension.D3_METRICS], ) tools = registry.suggest_tools(max_tools=3) assert len(tools) == 3 def test_priority_ordering(self): registry = MCPToolRegistry() provider = _StubProvider("test", []) registry.register_tool_manually( tool=_make_tool("low_priority"), provider=provider, dimensions=[SensorDimension.D3_METRICS], priority=8, ) registry.register_tool_manually( tool=_make_tool("high_priority"), provider=provider, dimensions=[SensorDimension.D1_K8S_STATE], priority=1, ) tools = registry.suggest_tools() assert tools[0].tool.name == "high_priority" assert tools[1].tool.name == "low_priority" def test_disabled_provider_excluded(self): registry = MCPToolRegistry() enabled_p = _StubProvider("enabled", [], enabled=True) disabled_p = _StubProvider("disabled", [], enabled=False) registry.register_tool_manually( tool=_make_tool("disabled_tool"), provider=disabled_p, dimensions=[SensorDimension.D1_K8S_STATE], ) registry.register_tool_manually( tool=_make_tool("enabled_tool"), provider=enabled_p, dimensions=[SensorDimension.D1_K8S_STATE], ) tools = registry.suggest_tools() names = [t.tool.name for t in tools] assert "enabled_tool" in names assert "disabled_tool" not in names def test_empty_registry_returns_empty(self): registry = MCPToolRegistry() assert registry.suggest_tools() == [] @pytest.mark.asyncio async def test_host_error_log_flood_gets_host_observability_tools(self): registry = MCPToolRegistry() ssh_provider = _StubProvider( "ssh_host", [ "ssh_diagnose", "ssh_get_top_processes", "ssh_get_container_logs", "ssh_get_container_status", "ssh_get_service_status", "ssh_check_port", ], ) k8s_provider = _StubProvider("kubernetes", ["kubectl_describe"]) signoz_provider = _StubProvider("signoz", ["query_logs"]) prometheus_provider = _StubProvider("prometheus", ["prometheus_query"]) await registry.register_provider(k8s_provider) await registry.register_provider(ssh_provider) await registry.register_provider(signoz_provider) await registry.register_provider(prometheus_provider) tools = registry.suggest_tools( alertname="HostErrorLogFlood", incident_labels={ "target": "ollama", "sensor_host": "ollama", "sensor_ip": "192.168.0.188", "host": "192.168.0.188", }, max_tools=8, ) names = [reg.tool.name for reg in tools] assert "ssh_diagnose" in names assert "ssh_get_top_processes" in names assert "query_logs" in names assert "prometheus_query" in names assert "kubectl_describe" not in names @pytest.mark.asyncio async def test_docker_container_alert_does_not_treat_container_as_pod_locator(self): registry = MCPToolRegistry() ssh_provider = _StubProvider("ssh_host", ["ssh_diagnose", "ssh_get_container_status"]) k8s_provider = _StubProvider("kubernetes", ["kubectl_describe", "k8s_get_pod_logs"]) prometheus_provider = _StubProvider("prometheus", ["prometheus_query"]) await registry.register_provider(k8s_provider) await registry.register_provider(ssh_provider) await registry.register_provider(prometheus_provider) tools = registry.suggest_tools( alertname="DockerContainerMemoryLimitPressure", incident_labels={ "namespace": "default", "container": "momo-pro-system", "container_name": "momo-pro-system", "host": "188", }, max_tools=8, ) names = [reg.tool.name for reg in tools] assert "ssh_diagnose" in names assert "ssh_get_container_status" in names assert "prometheus_query" in names assert "kubectl_describe" not in names assert "k8s_get_pod_logs" not in names def test_get_all_tools_returns_all(self): registry = MCPToolRegistry() provider = _StubProvider("test", []) registry.register_tool_manually( tool=_make_tool("t1"), provider=provider, dimensions=[SensorDimension.D1_K8S_STATE], ) registry.register_tool_manually( tool=_make_tool("t2"), provider=provider, dimensions=[SensorDimension.D3_METRICS], ) assert len(registry.get_all_tools()) == 2