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>
This commit is contained in:
tegwick 2026-07-22 21:48:09 +02:00
parent ba2612682f
commit b4b1dc1c7b
7 changed files with 284 additions and 29 deletions

View file

@ -26,6 +26,14 @@ Phase 1 runtime is implemented:
- REST endpoints: `/v1/health`, `/v1/accounts`, `/v1/transactions`, `/v1/snapshot` - REST endpoints: `/v1/health`, `/v1/accounts`, `/v1/transactions`, `/v1/snapshot`
- audit metadata, rate limiting, concurrency bounds, tests - audit metadata, rate limiting, concurrency bounds, tests
Phase 2 (MCP surface, `workplans/QONTO-WP-0003-mcp-surface.md`) is in
progress: a streamable-HTTP MCP adapter is mounted at `/mcp` on the same
capability core and policy kernel as REST, with tools `qonto_org_summary`,
`qonto_list_transactions`, and `qonto_cost_run_rate_hints`. Client auth is
still the REST-style `X-Actor-*` header convention; real OIDC/workload auth,
the shared multi-harness client config snippet, and the `finance-qonto-read`
tool profile are not yet implemented.
Current verification: Current verification:
- `PYTHONPATH=src ../state-hub/.venv/bin/python -m pytest``16 passed` - `PYTHONPATH=src ../state-hub/.venv/bin/python -m pytest``16 passed`

View file

@ -40,7 +40,7 @@ def create_app(
concurrency_limiter=concurrency_limiter, concurrency_limiter=concurrency_limiter,
) )
mcp_server = create_mcp_server(settings=settings) mcp_server = create_mcp_server(settings=settings, service=service)
mcp_app = mcp_server.streamable_http_app() mcp_app = mcp_server.streamable_http_app()
@asynccontextmanager @asynccontextmanager

View file

@ -1,15 +1,26 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from fastapi import Request from fastapi import Request
from qonto_assistant.config import Settings from qonto_assistant.config import Settings
from qonto_assistant.contracts import ActorClaims from qonto_assistant.contracts import ActorClaims
def actor_claims_from_request(request: Request, settings: Settings) -> ActorClaims: def actor_claims_from_headers(headers: Mapping[str, str], settings: Settings) -> ActorClaims:
actor_id = request.headers.get("x-actor-id", "anonymous") """Shared REST/MCP claims parsing so both transports enforce identical actor identity.
tenant_id = request.headers.get("x-tenant-id", settings.default_tenant_id)
lane = request.headers.get("x-actor-lane", settings.default_actor_lane) Real workload/OIDC auth for the MCP transport is QONTO-WP-0003-T03; until
raw_scopes = request.headers.get("x-actor-scopes", "") then MCP callers use the same X-Actor-* header convention as REST.
"""
actor_id = headers.get("x-actor-id", "anonymous")
tenant_id = headers.get("x-tenant-id", settings.default_tenant_id)
lane = headers.get("x-actor-lane", settings.default_actor_lane)
raw_scopes = headers.get("x-actor-scopes", "")
scopes = frozenset(scope.strip() for scope in raw_scopes.split(",") if scope.strip()) scopes = frozenset(scope.strip() for scope in raw_scopes.split(",") if scope.strip())
return ActorClaims(actor_id=actor_id, tenant_id=tenant_id, lane=lane, scopes=scopes) return ActorClaims(actor_id=actor_id, tenant_id=tenant_id, lane=lane, scopes=scopes)
def actor_claims_from_request(request: Request, settings: Settings) -> ActorClaims:
return actor_claims_from_headers(request.headers, settings)

View file

@ -1,20 +1,26 @@
from __future__ import annotations from __future__ import annotations
from mcp.server.fastmcp import FastMCP from collections.abc import Mapping
from typing import Any
from uuid import uuid4
from mcp.server.fastmcp import Context, FastMCP
from starlette.applications import Starlette from starlette.applications import Starlette
from qonto_assistant import __version__ from qonto_assistant import __version__
from qonto_assistant.auth import actor_claims_from_headers
from qonto_assistant.config import Settings from qonto_assistant.config import Settings
from qonto_assistant.service import CapabilityService
def create_mcp_server(*, settings: Settings) -> FastMCP: def create_mcp_server(*, settings: Settings, service: CapabilityService | None = None) -> FastMCP:
"""Build the MCP adapter on the same capability core as the REST surface. """Build the MCP adapter on the same capability core as the REST surface.
Phase 2 (QONTO-WP-0003) scope note: this skeleton exposes only a smoke Every tool here maps 1:1 to a REST-allowed capability id and goes through
tool. Real read tools (`qonto_org_summary`, `qonto_list_transactions`, `CapabilityService`, which in turn calls the shared
`qonto_cost_run_rate_hints`) land in QONTO-WP-0003-T02, routed through the `PolicyEngine.decide()` -- no separate MCP policy path. Client auth is
same `CapabilityRequest` -> `PolicyEngine.decide()` path REST already still the simple X-Actor-* header convention (QONTO-WP-0003-T03 upgrades
uses -- no policy fork between transports. this to real OIDC/workload auth); no bank secrets are ever exposed here.
""" """
server = FastMCP( server = FastMCP(
name=settings.service_name, name=settings.service_name,
@ -23,6 +29,10 @@ def create_mcp_server(*, settings: Settings) -> FastMCP:
"No spend, transfer, card, or volume-cost tools are exposed here." "No spend, transfer, card, or volume-cost tools are exposed here."
), ),
stateless_http=True, stateless_http=True,
# Mounted at "/mcp" by the parent FastAPI app (see app.py); keep this
# sub-app's own route at root so the final path is "/mcp", not
# "/mcp/mcp".
streamable_http_path="/",
) )
@server.tool() @server.tool()
@ -30,8 +40,80 @@ def create_mcp_server(*, settings: Settings) -> FastMCP:
"""Smoke tool confirming the MCP adapter is reachable. No bank call.""" """Smoke tool confirming the MCP adapter is reachable. No bank call."""
return {"status": "ok", "service": settings.service_name, "version": __version__} return {"status": "ok", "service": settings.service_name, "version": __version__}
if service is not None:
@server.tool()
def qonto_org_summary(ctx: Context) -> dict[str, Any]:
"""Organization name and per-account balances (org_summary capability)."""
claims = _claims(ctx, settings)
return service.get_accounts(claims=claims, request_id=_request_id(ctx), protocol="mcp")
@server.tool()
def qonto_list_transactions(
ctx: Context,
account_slug: str | None = None,
page: int = 1,
page_size: int = 50,
window_days: int = 31,
status: str | None = "completed",
side: str | None = None,
) -> dict[str, Any]:
"""Capped, filtered transaction history (list_transactions capability)."""
claims = _claims(ctx, settings)
return service.list_transactions(
claims=claims,
request_id=_request_id(ctx),
account_slug=account_slug,
page=page,
page_size=page_size,
window_days=window_days,
status=status,
side=side,
protocol="mcp",
)
@server.tool()
def qonto_cost_run_rate_hints(
ctx: Context,
window_days: int = 90,
page_size: int = 50,
) -> dict[str, Any]:
"""Normalized recurring-cost hints, not a raw export (cost_run_rate_hints capability)."""
claims = _claims(ctx, settings)
return service.get_cost_run_rate_hints(
claims=claims,
request_id=_request_id(ctx),
window_days=window_days,
page_size=page_size,
protocol="mcp",
)
return server return server
def mcp_asgi_app(*, settings: Settings) -> Starlette: def mcp_asgi_app(*, settings: Settings, service: CapabilityService | None = None) -> Starlette:
return create_mcp_server(settings=settings).streamable_http_app() return create_mcp_server(settings=settings, service=service).streamable_http_app()
def _claims(ctx: Context, settings: Settings):
headers = _headers_from_context(ctx)
return actor_claims_from_headers(headers, settings)
def _headers_from_context(ctx: Context) -> Mapping[str, str]:
# request_context is unset outside a live MCP request (e.g. stdio
# transport, or a tool invoked directly in tests) -- fall back to the
# same default-actor behavior REST uses for header-less callers.
try:
request_context = ctx.request_context
except ValueError:
return {}
request = getattr(request_context, "request", None)
headers = getattr(request, "headers", None)
return headers if headers is not None else {}
def _request_id(ctx: Context) -> str:
headers = _headers_from_context(ctx)
header_id = headers.get("x-request-id")
return header_id if header_id else str(uuid4())

View file

@ -7,7 +7,7 @@ from datetime import UTC, datetime
from typing import Any from typing import Any
from qonto_assistant.audit import AuditLogger, utc_now_iso from qonto_assistant.audit import AuditLogger, utc_now_iso
from qonto_assistant.contracts import ActorClaims, AuditEvent, CapabilityRequest from qonto_assistant.contracts import ActorClaims, AuditEvent, CapabilityRequest, ProtocolName
from qonto_assistant.errors import InvalidRequestError, PolicyDeniedError, UpstreamError from qonto_assistant.errors import InvalidRequestError, PolicyDeniedError, UpstreamError
from qonto_assistant.policy import PolicyEngine from qonto_assistant.policy import PolicyEngine
from qonto_assistant.qonto_client import QontoClientProtocol from qonto_assistant.qonto_client import QontoClientProtocol
@ -30,13 +30,16 @@ class CapabilityService:
self.rate_limiter = rate_limiter self.rate_limiter = rate_limiter
self.concurrency_limiter = concurrency_limiter self.concurrency_limiter = concurrency_limiter
def get_accounts(self, *, claims: ActorClaims, request_id: str) -> dict[str, Any]: def get_accounts(
self, *, claims: ActorClaims, request_id: str, protocol: ProtocolName = "rest"
) -> dict[str, Any]:
return self._execute( return self._execute(
capability_id="org_summary", capability_id="org_summary",
claims=claims, claims=claims,
request_args={}, request_args={},
resource_scope="accounts", resource_scope="accounts",
request_id=request_id, request_id=request_id,
protocol=protocol,
operation=self._build_accounts_payload, operation=self._build_accounts_payload,
) )
@ -51,6 +54,7 @@ class CapabilityService:
window_days: int, window_days: int,
status: str | None, status: str | None,
side: str | None, side: str | None,
protocol: ProtocolName = "rest",
) -> dict[str, Any]: ) -> dict[str, Any]:
return self._execute( return self._execute(
capability_id="list_transactions", capability_id="list_transactions",
@ -65,6 +69,7 @@ class CapabilityService:
}, },
resource_scope="transactions", resource_scope="transactions",
request_id=request_id, request_id=request_id,
protocol=protocol,
operation=lambda _: self._build_transactions_payload( operation=lambda _: self._build_transactions_payload(
account_slug=account_slug, account_slug=account_slug,
page=page, page=page,
@ -82,6 +87,7 @@ class CapabilityService:
request_id: str, request_id: str,
window_days: int, window_days: int,
page_size: int, page_size: int,
protocol: ProtocolName = "rest",
) -> dict[str, Any]: ) -> dict[str, Any]:
return self._execute( return self._execute(
capability_id="snapshot_bundle", capability_id="snapshot_bundle",
@ -89,9 +95,31 @@ class CapabilityService:
request_args={"window_days": window_days, "page_size": page_size}, request_args={"window_days": window_days, "page_size": page_size},
resource_scope="snapshot", resource_scope="snapshot",
request_id=request_id, request_id=request_id,
protocol=protocol,
operation=lambda _: self._build_snapshot_payload(window_days=window_days, page_size=page_size), operation=lambda _: self._build_snapshot_payload(window_days=window_days, page_size=page_size),
) )
def get_cost_run_rate_hints(
self,
*,
claims: ActorClaims,
request_id: str,
window_days: int,
page_size: int,
protocol: ProtocolName = "rest",
) -> dict[str, Any]:
return self._execute(
capability_id="cost_run_rate_hints",
claims=claims,
request_args={"window_days": window_days, "page_size": page_size},
resource_scope="cost_run_rate_hints",
request_id=request_id,
protocol=protocol,
operation=lambda _: self._build_cost_run_rate_hints_payload(
window_days=window_days, page_size=page_size
),
)
def _execute( def _execute(
self, self,
*, *,
@ -101,6 +129,7 @@ class CapabilityService:
resource_scope: str, resource_scope: str,
request_id: str, request_id: str,
operation, operation,
protocol: ProtocolName = "rest",
) -> dict[str, Any]: ) -> dict[str, Any]:
request = CapabilityRequest( request = CapabilityRequest(
capability_id=capability_id, capability_id=capability_id,
@ -108,7 +137,7 @@ class CapabilityService:
actor_claims=claims, actor_claims=claims,
resource_scope=resource_scope, resource_scope=resource_scope,
request_args=request_args, request_args=request_args,
protocol="rest", protocol=protocol,
) )
started = time.perf_counter() started = time.perf_counter()
decision = self.policy.decide(request) decision = self.policy.decide(request)
@ -122,6 +151,7 @@ class CapabilityService:
latency_ms=_latency_ms(started), latency_ms=_latency_ms(started),
result_count=None, result_count=None,
qonto_http_status=None, qonto_http_status=None,
protocol=protocol,
) )
raise PolicyDeniedError(decision) raise PolicyDeniedError(decision)
@ -140,6 +170,7 @@ class CapabilityService:
latency_ms=_latency_ms(started), latency_ms=_latency_ms(started),
result_count=_result_count(payload), result_count=_result_count(payload),
qonto_http_status=200, qonto_http_status=200,
protocol=protocol,
) )
return payload return payload
@ -216,6 +247,28 @@ class CapabilityService:
"recent_transactions": recent_transactions[:10], "recent_transactions": recent_transactions[:10],
} }
def _build_cost_run_rate_hints_payload(self, *, window_days: int, page_size: int) -> dict[str, Any]:
accounts_payload = self._build_accounts_payload({})
accounts = accounts_payload["accounts"]
main_account = next((account for account in accounts if account["main"]), accounts[0] if accounts else None)
recent_transactions: list[dict[str, Any]] = []
if main_account is not None:
transactions_payload = self._build_transactions_payload(
account_slug=main_account["slug"],
page=1,
page_size=min(page_size, 50),
window_days=window_days,
status="completed",
side=None,
)
recent_transactions = transactions_payload["transactions"]
return {
"organization": accounts_payload["organization"],
"window_days": window_days,
"cost_run_rate_hints": _build_cost_run_rate_hints(recent_transactions),
}
def _emit_audit( def _emit_audit(
self, self,
*, *,
@ -227,6 +280,7 @@ class CapabilityService:
latency_ms: int, latency_ms: int,
result_count: int | None, result_count: int | None,
qonto_http_status: int | None, qonto_http_status: int | None,
protocol: ProtocolName = "rest",
) -> None: ) -> None:
event = AuditEvent( event = AuditEvent(
request_id=request_id, request_id=request_id,
@ -234,7 +288,7 @@ class CapabilityService:
actor=claims.actor_id, actor=claims.actor_id,
tenant_id=claims.tenant_id, tenant_id=claims.tenant_id,
capability=capability_id, capability=capability_id,
protocol="rest", protocol=protocol,
decision=decision, decision=decision,
deny_reason=deny_reason, deny_reason=deny_reason,
policy_version=self.policy.version, policy_version=self.policy.version,

View file

@ -1,15 +1,37 @@
import pytest 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.config import Settings
from qonto_assistant.mcp_server import create_mcp_server 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: def _settings() -> Settings:
settings = Settings.from_env() return Settings.from_env()
return settings
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
@pytest.mark.asyncio
async def test_mcp_server_starts_and_lists_smoke_tool() -> None: async def test_mcp_server_starts_and_lists_smoke_tool() -> None:
server = create_mcp_server(settings=_settings()) server = create_mcp_server(settings=_settings())
tools = await server.list_tools() tools = await server.list_tools()
@ -17,9 +39,63 @@ async def test_mcp_server_starts_and_lists_smoke_tool() -> None:
assert "qonto_ping" in tool_names assert "qonto_ping" in tool_names
@pytest.mark.asyncio async def test_mcp_server_without_service_only_exposes_smoke_tool() -> None:
async def test_mcp_server_smoke_tool_returns_no_bank_call() -> None:
server = create_mcp_server(settings=_settings()) server = create_mcp_server(settings=_settings())
result = await server.call_tool("qonto_ping", {}) tools = await server.list_tools()
payload = result[1] if isinstance(result, tuple) else result tool_names = {tool.name for tool in tools}
assert payload is not None 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", {})

View file

@ -70,7 +70,7 @@ QONTO-WP-0003-T02.
```task ```task
id: QONTO-WP-0003-T02 id: QONTO-WP-0003-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "f9e0d403-d6ae-4800-8d42-847c3d70827f" state_hub_task_id: "f9e0d403-d6ae-4800-8d42-847c3d70827f"
``` ```
@ -94,6 +94,30 @@ Done when: unit tests cover allow paths for each tool and deny paths for
out-of-catalog / spend-shaped tool names, mirroring the REST policy tests from out-of-catalog / spend-shaped tool names, mirroring the REST policy tests from
QONTO-WP-0002-T02. QONTO-WP-0002-T02.
**Done 2026-07-22:** Implemented `qonto_org_summary`, `qonto_list_transactions`,
and `qonto_cost_run_rate_hints` in `mcp_server.py`, all routed through
`CapabilityService` with `protocol="mcp"` (skipped `snapshot_bundle` — REST
already covers the composite read and it isn't a separate privilege).
`CapabilityService` now threads `protocol: ProtocolName` through `_execute`
and `_emit_audit` instead of hardcoding `"rest"`; added
`get_cost_run_rate_hints()` + `_build_cost_run_rate_hints_payload()` since
`cost_run_rate_hints` is its own policy capability, not only a snapshot
sub-field. Actor identity uses the same `X-Actor-*` header convention as
REST — `auth.py` now exposes a shared `actor_claims_from_headers()`, read
from the MCP `Context`'s underlying Starlette request when present, falling
back to defaults for stdio/no-request-context callers. Fixed a routing bug:
`FastMCP`'s default `streamable_http_path="/mcp"` plus mounting at `/mcp`
doubled to `/mcp/mcp` — set `streamable_http_path="/"` on the sub-app instead.
Verified: `tests/test_mcp_server.py` covers tool listing, an allow path
(`qonto_org_summary`, asserts redacted output + `protocol: "mcp"` audit
event), a deny path (`qonto_list_transactions` oversized `page_size`
`ToolError`, `deny_reason: "arg_constraint"`), and `qonto_cost_run_rate_hints`.
Also ran a real end-to-end check with the `mcp` SDK's `streamablehttp_client`
against the live server (fixture-backed): tool list, `qonto_org_summary` call,
and confirmed `X-Actor-ID` header flows through to the audit log exactly like
REST. `pytest``25 passed`; `python3 -m compileall src tests scripts`.
## Task: Client auth and one shared config snippet ## Task: Client auth and one shared config snippet
```task ```task