qonto-assistant/tests/test_mcp_server.py
tegwick b4b1dc1c7b QONTO-WP-0003-T02: MCP tool catalog on the shared capability core
Add qonto_org_summary, qonto_list_transactions, and qonto_cost_run_rate_hints
MCP tools, all routed through CapabilityService with protocol="mcp" -- same
PolicyEngine.decide() path as REST, same deny-reason vocabulary. Skip
snapshot_bundle as an MCP tool (REST already covers the composite read; not
a separate privilege).

CapabilityService now threads protocol through _execute/_emit_audit instead
of hardcoding "rest". cost_run_rate_hints gets its own service method since
it's an independent policy capability, not only a snapshot sub-field.

Actor identity reuses REST's X-Actor-* header convention via a shared
auth.actor_claims_from_headers(), read from the MCP Context's request when
present. Fixed streamable_http_path defaulting to "/mcp", which doubled to
"/mcp/mcp" once mounted under the "/mcp" prefix.

Verified end-to-end with the mcp SDK's streamablehttp_client against the
live fixture-backed server: tool list, allow/deny paths, and X-Actor-ID
flowing through to the audit log exactly like REST.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 21:48:09 +02:00

101 lines
3.7 KiB
Python

from pathlib import Path
import pytest
from mcp.server.fastmcp.exceptions import ToolError
from qonto_assistant.audit import AuditLogger
from qonto_assistant.config import Settings
from qonto_assistant.mcp_server import create_mcp_server
from qonto_assistant.policy import PolicyEngine
from qonto_assistant.qonto_client import FixtureQontoClient
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
from qonto_assistant.service import CapabilityService
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
def _settings() -> Settings:
return Settings.from_env()
def _service() -> tuple[CapabilityService, list[dict[str, object]]]:
events: list[dict[str, object]] = []
policy = PolicyEngine.from_file(POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False)
service = CapabilityService(
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
policy=policy,
audit_logger=AuditLogger(sink=events.append),
rate_limiter=RateLimiter(limit=20, window_seconds=60),
concurrency_limiter=ConcurrencyLimiter(limit=4),
)
return service, events
async def test_mcp_server_starts_and_lists_smoke_tool() -> None:
server = create_mcp_server(settings=_settings())
tools = await server.list_tools()
tool_names = {tool.name for tool in tools}
assert "qonto_ping" in tool_names
async def test_mcp_server_without_service_only_exposes_smoke_tool() -> None:
server = create_mcp_server(settings=_settings())
tools = await server.list_tools()
tool_names = {tool.name for tool in tools}
assert tool_names == {"qonto_ping"}
async def test_mcp_server_lists_capability_tools_when_service_wired() -> None:
service, _ = _service()
server = create_mcp_server(settings=_settings(), service=service)
tools = await server.list_tools()
tool_names = {tool.name for tool in tools}
assert tool_names == {
"qonto_ping",
"qonto_org_summary",
"qonto_list_transactions",
"qonto_cost_run_rate_hints",
}
async def test_qonto_org_summary_tool_returns_redacted_balances() -> None:
service, events = _service()
server = create_mcp_server(settings=_settings(), service=service)
result = await server.call_tool("qonto_org_summary", {})
assert result[1]["organization"]["name"] == "Binky Hedgehog GmbH"
assert "iban" not in result[1]["accounts"][0]
assert events[-1]["capability"] == "org_summary"
assert events[-1]["protocol"] == "mcp"
async def test_qonto_list_transactions_tool_denies_oversized_page_size() -> None:
service, events = _service()
server = create_mcp_server(settings=_settings(), service=service)
with pytest.raises(ToolError):
await server.call_tool("qonto_list_transactions", {"page_size": 101})
assert events[-1]["decision"] == "deny"
assert events[-1]["deny_reason"] == "arg_constraint"
assert events[-1]["protocol"] == "mcp"
async def test_qonto_cost_run_rate_hints_tool_returns_recurring_debits() -> None:
service, events = _service()
server = create_mcp_server(settings=_settings(), service=service)
result = await server.call_tool("qonto_cost_run_rate_hints", {"window_days": 90})
hints = result[1]["cost_run_rate_hints"]["recurring_debits"]
assert len(hints) > 0
assert all("amount" in hint for hint in hints)
assert events[-1]["capability"] == "cost_run_rate_hints"
async def test_capability_tools_are_absent_without_service() -> None:
server = create_mcp_server(settings=_settings())
with pytest.raises(ToolError):
await server.call_tool("qonto_org_summary", {})