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

@ -40,7 +40,7 @@ def create_app(
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()
@asynccontextmanager

View file

@ -1,15 +1,26 @@
from __future__ import annotations
from collections.abc import Mapping
from fastapi import Request
from qonto_assistant.config import Settings
from qonto_assistant.contracts import ActorClaims
def actor_claims_from_request(request: Request, settings: Settings) -> ActorClaims:
actor_id = request.headers.get("x-actor-id", "anonymous")
tenant_id = request.headers.get("x-tenant-id", settings.default_tenant_id)
lane = request.headers.get("x-actor-lane", settings.default_actor_lane)
raw_scopes = request.headers.get("x-actor-scopes", "")
def actor_claims_from_headers(headers: Mapping[str, str], settings: Settings) -> ActorClaims:
"""Shared REST/MCP claims parsing so both transports enforce identical actor identity.
Real workload/OIDC auth for the MCP transport is QONTO-WP-0003-T03; until
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())
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 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 qonto_assistant import __version__
from qonto_assistant.auth import actor_claims_from_headers
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.
Phase 2 (QONTO-WP-0003) scope note: this skeleton exposes only a smoke
tool. Real read tools (`qonto_org_summary`, `qonto_list_transactions`,
`qonto_cost_run_rate_hints`) land in QONTO-WP-0003-T02, routed through the
same `CapabilityRequest` -> `PolicyEngine.decide()` path REST already
uses -- no policy fork between transports.
Every tool here maps 1:1 to a REST-allowed capability id and goes through
`CapabilityService`, which in turn calls the shared
`PolicyEngine.decide()` -- no separate MCP policy path. Client auth is
still the simple X-Actor-* header convention (QONTO-WP-0003-T03 upgrades
this to real OIDC/workload auth); no bank secrets are ever exposed here.
"""
server = FastMCP(
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."
),
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()
@ -30,8 +40,80 @@ def create_mcp_server(*, settings: Settings) -> FastMCP:
"""Smoke tool confirming the MCP adapter is reachable. No bank call."""
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
def mcp_asgi_app(*, settings: Settings) -> Starlette:
return create_mcp_server(settings=settings).streamable_http_app()
def mcp_asgi_app(*, settings: Settings, service: CapabilityService | None = None) -> Starlette:
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 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.policy import PolicyEngine
from qonto_assistant.qonto_client import QontoClientProtocol
@ -30,13 +30,16 @@ class CapabilityService:
self.rate_limiter = rate_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(
capability_id="org_summary",
claims=claims,
request_args={},
resource_scope="accounts",
request_id=request_id,
protocol=protocol,
operation=self._build_accounts_payload,
)
@ -51,6 +54,7 @@ class CapabilityService:
window_days: int,
status: str | None,
side: str | None,
protocol: ProtocolName = "rest",
) -> dict[str, Any]:
return self._execute(
capability_id="list_transactions",
@ -65,6 +69,7 @@ class CapabilityService:
},
resource_scope="transactions",
request_id=request_id,
protocol=protocol,
operation=lambda _: self._build_transactions_payload(
account_slug=account_slug,
page=page,
@ -82,6 +87,7 @@ class CapabilityService:
request_id: str,
window_days: int,
page_size: int,
protocol: ProtocolName = "rest",
) -> dict[str, Any]:
return self._execute(
capability_id="snapshot_bundle",
@ -89,9 +95,31 @@ class CapabilityService:
request_args={"window_days": window_days, "page_size": page_size},
resource_scope="snapshot",
request_id=request_id,
protocol=protocol,
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(
self,
*,
@ -101,6 +129,7 @@ class CapabilityService:
resource_scope: str,
request_id: str,
operation,
protocol: ProtocolName = "rest",
) -> dict[str, Any]:
request = CapabilityRequest(
capability_id=capability_id,
@ -108,7 +137,7 @@ class CapabilityService:
actor_claims=claims,
resource_scope=resource_scope,
request_args=request_args,
protocol="rest",
protocol=protocol,
)
started = time.perf_counter()
decision = self.policy.decide(request)
@ -122,6 +151,7 @@ class CapabilityService:
latency_ms=_latency_ms(started),
result_count=None,
qonto_http_status=None,
protocol=protocol,
)
raise PolicyDeniedError(decision)
@ -140,6 +170,7 @@ class CapabilityService:
latency_ms=_latency_ms(started),
result_count=_result_count(payload),
qonto_http_status=200,
protocol=protocol,
)
return payload
@ -216,6 +247,28 @@ class CapabilityService:
"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(
self,
*,
@ -227,6 +280,7 @@ class CapabilityService:
latency_ms: int,
result_count: int | None,
qonto_http_status: int | None,
protocol: ProtocolName = "rest",
) -> None:
event = AuditEvent(
request_id=request_id,
@ -234,7 +288,7 @@ class CapabilityService:
actor=claims.actor_id,
tenant_id=claims.tenant_id,
capability=capability_id,
protocol="rest",
protocol=protocol,
decision=decision,
deny_reason=deny_reason,
policy_version=self.policy.version,