diff --git a/README.md b/README.md index 8363bb3..c70eb57 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,14 @@ Phase 1 runtime is implemented: - REST endpoints: `/v1/health`, `/v1/accounts`, `/v1/transactions`, `/v1/snapshot` - 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: - `PYTHONPATH=src ../state-hub/.venv/bin/python -m pytest` → `16 passed` diff --git a/src/qonto_assistant/app.py b/src/qonto_assistant/app.py index 9a2b88f..7bfac9e 100644 --- a/src/qonto_assistant/app.py +++ b/src/qonto_assistant/app.py @@ -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 diff --git a/src/qonto_assistant/auth.py b/src/qonto_assistant/auth.py index d05e229..c1d0c02 100644 --- a/src/qonto_assistant/auth.py +++ b/src/qonto_assistant/auth.py @@ -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) diff --git a/src/qonto_assistant/mcp_server.py b/src/qonto_assistant/mcp_server.py index 96a0724..b580686 100644 --- a/src/qonto_assistant/mcp_server.py +++ b/src/qonto_assistant/mcp_server.py @@ -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()) diff --git a/src/qonto_assistant/service.py b/src/qonto_assistant/service.py index 83c7235..9c6ae8d 100644 --- a/src/qonto_assistant/service.py +++ b/src/qonto_assistant/service.py @@ -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, diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 3e8ab0a..8485f88 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -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.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: - settings = Settings.from_env() - return 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 -@pytest.mark.asyncio async def test_mcp_server_starts_and_lists_smoke_tool() -> None: server = create_mcp_server(settings=_settings()) 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 -@pytest.mark.asyncio -async def test_mcp_server_smoke_tool_returns_no_bank_call() -> None: +async def test_mcp_server_without_service_only_exposes_smoke_tool() -> None: server = create_mcp_server(settings=_settings()) - result = await server.call_tool("qonto_ping", {}) - payload = result[1] if isinstance(result, tuple) else result - assert payload is not None + 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", {}) diff --git a/workplans/QONTO-WP-0003-mcp-surface.md b/workplans/QONTO-WP-0003-mcp-surface.md index c3c11ca..90018b7 100644 --- a/workplans/QONTO-WP-0003-mcp-surface.md +++ b/workplans/QONTO-WP-0003-mcp-surface.md @@ -70,7 +70,7 @@ QONTO-WP-0003-T02. ```task id: QONTO-WP-0003-T02 -status: todo +status: done priority: high 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 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