fix(mcp): tag legacy provider calls with audit context
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / tests (push) Successful in 59s
CD Pipeline / build-and-deploy (push) Successful in 3m22s
CD Pipeline / post-deploy-checks (push) Successful in 1m19s

This commit is contained in:
Your Name
2026-05-06 17:18:52 +08:00
parent 76aaaf480c
commit fa0e956c0e
11 changed files with 300 additions and 11 deletions

View File

@@ -15,6 +15,7 @@ Phase 5 Sprint 5.0-5.1 Callback Dispatcher 單元測試
import pytest
from src.plugins.mcp.interfaces import MCPTool, MCPToolProvider, MCPToolResult
from src.services import callback_dispatcher as callback_dispatcher_module
from src.services.callback_dispatcher import (
dispatch_action,
@@ -320,3 +321,38 @@ class TestMcpFailurePath:
# 但絕不能 crash
assert isinstance(result.success, bool)
assert result.result_text # 有合理的錯誤訊息
class _CaptureMcpProvider(MCPToolProvider):
name = "ssh_host"
seen_parameters: dict | None = None
async def list_tools(self) -> list[MCPTool]:
return []
async def execute(self, tool_name: str, parameters: dict) -> MCPToolResult:
self.seen_parameters = dict(parameters)
return MCPToolResult(success=True, output={"stdout": "ok"})
@pytest.mark.asyncio
async def test_dispatch_action_injects_mcp_audit_context(monkeypatch):
provider = _CaptureMcpProvider()
monkeypatch.setattr("src.plugins.mcp.registry.get_provider", lambda name: provider)
result = await dispatch_action(
action_name="check_process",
incident_id="INC-CB-AUDIT",
user_id=12345,
labels={"instance": "192.168.0.110"},
)
assert result.success is True
assert provider.seen_parameters is not None
audit_context = provider.seen_parameters["_mcp_audit"]
assert audit_context["incident_id"] == "INC-CB-AUDIT"
assert audit_context["session_id"] == "callback:INC-CB-AUDIT:check_process"
assert audit_context["flywheel_node"] == "operate"
assert audit_context["agent_role"] == "telegram_callback_dispatcher"
assert audit_context["operator_user_id"] == 12345

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from src.services.mcp_audit_context import (
build_mcp_audit_context,
with_mcp_audit_context,
)
def test_build_mcp_audit_context_keeps_non_empty_fields() -> None:
context = build_mcp_audit_context(
session_id="incident:INC-1:pre_decision",
incident_id="INC-1",
flywheel_node="sense",
agent_role="pre_decision_investigator",
operator_user_id=None,
)
assert context == {
"gateway_path": "legacy_registry_provider",
"session_id": "incident:INC-1:pre_decision",
"incident_id": "INC-1",
"flywheel_node": "sense",
"agent_role": "pre_decision_investigator",
}
def test_with_mcp_audit_context_merges_existing_context_without_mutating_source() -> None:
params = {
"namespace": "awoooi-prod",
"_mcp_audit": {"trace_id": "trace-1", "flywheel_node": "old"},
}
audited = with_mcp_audit_context(
params,
incident_id="INC-2",
flywheel_node="verify",
agent_role="post_execution_verifier",
)
assert params["_mcp_audit"]["flywheel_node"] == "old"
assert audited is not params
assert audited["_mcp_audit"]["trace_id"] == "trace-1"
assert audited["_mcp_audit"]["incident_id"] == "INC-2"
assert audited["_mcp_audit"]["flywheel_node"] == "verify"
assert audited["_mcp_audit"]["agent_role"] == "post_execution_verifier"

View File

@@ -22,6 +22,7 @@ import asyncio
import pytest
from unittest.mock import AsyncMock, patch
from src.plugins.mcp.interfaces import MCPTool, MCPToolProvider, MCPToolResult
from src.services.post_execution_verifier import (
PostExecutionVerifier,
_assess_recovery,
@@ -29,6 +30,7 @@ from src.services.post_execution_verifier import (
_get_labels,
)
from src.services.evidence_snapshot import EvidenceSnapshot
from src.services.mcp_tool_registry import RegisteredTool, SensorDimension
# ─────────────────────────────────────────────────────────────────────────────
@@ -306,3 +308,52 @@ class TestCapturePreState:
await verifier.capture_pre_execution_state(incident, snapshot)
assert snapshot.pre_execution_state == {}
class _CaptureProvider(MCPToolProvider):
name = "capture"
seen_parameters: dict | None = None
async def list_tools(self) -> list[MCPTool]:
return []
async def execute(self, tool_name: str, parameters: dict) -> MCPToolResult:
self.seen_parameters = dict(parameters)
return MCPToolResult(success=True, output={"status": "Running"})
class _FakeRegistry:
def __init__(self, provider: _CaptureProvider) -> None:
self.provider = provider
def suggest_tools(self, alertname: str = "", incident_labels: dict | None = None) -> list[RegisteredTool]:
return [
RegisteredTool(
tool=MCPTool(
name="kubectl_get",
description="",
input_schema={},
server_name="capture",
),
provider=self.provider,
dimensions=[SensorDimension.D1_K8S_STATE],
)
]
class TestCollectPostStateAuditContext:
@pytest.mark.asyncio
async def test_collect_post_state_injects_mcp_audit_context(self):
provider = _CaptureProvider()
verifier = PostExecutionVerifier()
verifier._registry = _FakeRegistry(provider)
state = await verifier._collect_post_state(_stub_incident())
assert state["kubectl_get"]["status"] == "Running"
assert provider.seen_parameters is not None
audit_context = provider.seen_parameters["_mcp_audit"]
assert audit_context["incident_id"] == "INC-TEST"
assert audit_context["session_id"] == "incident:INC-TEST:post_execution"
assert audit_context["flywheel_node"] == "verify"
assert audit_context["agent_role"] == "post_execution_verifier"

View File

@@ -91,6 +91,18 @@ class _TimeoutProvider(MCPToolProvider):
return MCPToolResult(success=True, execution_id="never", output={})
class _CaptureProvider(_SuccessProvider):
"""成功 provider額外保存收到的參數。"""
def __init__(self) -> None:
super().__init__({"status": "Running"})
self.seen_parameters: dict | None = None
async def execute(self, tool_name: str, parameters: dict) -> MCPToolResult:
self.seen_parameters = dict(parameters)
return await super().execute(tool_name, parameters)
def _stub_incident(
alertname: str = "KubePodCrashLooping",
namespace: str = "awoooi-prod",
@@ -268,6 +280,22 @@ class TestCollectOne:
assert snap.sensors_succeeded == 1
assert snap.k8s_state is not None
@pytest.mark.asyncio
async def test_collect_one_injects_mcp_audit_context(self):
investigator = PreDecisionInvestigator()
snap = EvidenceSnapshot(incident_id="INC-AUDIT")
provider = _CaptureProvider()
reg = _reg("kubectl_describe", provider, SensorDimension.D1_K8S_STATE)
await investigator._collect_one(snap, reg, {"namespace": "prod"})
assert provider.seen_parameters is not None
audit_context = provider.seen_parameters["_mcp_audit"]
assert audit_context["incident_id"] == "INC-AUDIT"
assert audit_context["session_id"] == "incident:INC-AUDIT:pre_decision"
assert audit_context["flywheel_node"] == "sense"
assert audit_context["agent_role"] == "pre_decision_investigator"
@pytest.mark.asyncio
async def test_failed_tool_marks_health_false(self):
investigator = PreDecisionInvestigator()