diff --git a/README.md b/README.md index c70eb57..5487d1f 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,11 @@ Phase 1 runtime is implemented: 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. +`qonto_list_transactions`, and `qonto_cost_run_rate_hints`. The endpoint is +gated by a shared-secret bearer token (`QONTO_ASSISTANT_MCP_TOKEN`) — see +`docs/mcp-integration.md` for the auth model, its gap vs. the OIDC/workload +target, and the shared multi-harness client config snippet. The +`finance-qonto-read` tool profile is not yet implemented. Current verification: diff --git a/docs/mcp-integration.md b/docs/mcp-integration.md new file mode 100644 index 0000000..15d905d --- /dev/null +++ b/docs/mcp-integration.md @@ -0,0 +1,119 @@ +# MCP Integration (Phase 2) + +`qonto-assistant` exposes a streamable-HTTP MCP adapter at `/mcp`, mounted on +the same FastAPI process as the REST surface. It shares the exact policy +kernel and audit layer REST uses — see `specs/ArchitectureBlueprint.md` §6. + +Tools exposed (read-only, default-deny policy underneath): + +| MCP tool | Capability | Notes | +| --- | --- | --- | +| `qonto_ping` | none (no bank call) | Smoke/liveness check | +| `qonto_org_summary` | `org_summary` | Organization + per-account balances | +| `qonto_list_transactions` | `list_transactions` | Capped, filtered history | +| `qonto_cost_run_rate_hints` | `cost_run_rate_hints` | Normalized recurring-cost hints, not a raw export | + +No spend, transfer, card, or volume-cost tool is ever exposed here — those +stay hard-denied by the shared policy kernel regardless of transport. + +## Auth model + +Two independent layers, same as the blueprint's identity separation +(`specs/ArchitectureBlueprint.md` §4.7): + +1. **Workload auth (is this caller allowed to reach the service at all?)** + Today: a static shared-secret bearer token + (`QONTO_ASSISTANT_MCP_TOKEN`), checked by `BearerTokenAuthMiddleware` + in front of `/mcp`. If the env var is unset, the middleware is not + installed — used only for local fixture-backed smoke work, never for a + deployment holding real credentials. + + This is **not** the OIDC/workload-identity target the blueprint + describes — there is no OIDC issuer in this fleet yet to federate + against. A shared-secret bearer token is the deployable "or similar" + primitive for now; upgrading to real OIDC/workload identity is + fleet-level follow-on work (tracked as a Phase 2 gap, not closed by this + task). + + This token is a **service credential**, not a bank credential — it never + reaches Qonto and is never logged. Treat it like any other shared + secret: store it in OpenBao or your harness's secret manager, not in + plaintext config committed to a repo. + +2. **Actor identity (who is calling, for policy and audit purposes?)** + Same `X-Actor-ID` / `X-Tenant-ID` / `X-Actor-Lane` / `X-Actor-Scopes` + header convention REST already uses (`auth.actor_claims_from_headers`). + These are self-asserted today, not cryptographically bound to the bearer + token — tightening that binding is exactly what Phase 3's flex-auth + resource (`finance.qonto.read`) is for. + +## One shared client config snippet + +The same `url` + `headers` shape works across MCP-capable harnesses that +support a remote streamable-HTTP server (Claude Code, Claude Desktop, +Cursor, and Codex/Grok-style harnesses that follow the MCP client config +convention): + +```json +{ + "mcpServers": { + "qonto-assistant": { + "url": "http://127.0.0.1:8080/mcp", + "headers": { + "Authorization": "Bearer ${QONTO_ASSISTANT_MCP_TOKEN}", + "X-Actor-ID": "${AGENT_ACTOR_ID}", + "X-Tenant-ID": "binky" + } + } + } +} +``` + +Replace the `url` host/port with wherever the service is actually deployed. +`${QONTO_ASSISTANT_MCP_TOKEN}` and `${AGENT_ACTOR_ID}` are placeholders for +whatever secret-interpolation syntax your harness config supports — never +paste the literal token into a shared config file. + +## Local smoke: start with auth enabled, connect with only this snippet + +```bash +export QONTO_FIXTURE_DIR=tests/fixtures/qonto +export QONTO_ASSISTANT_MCP_TOKEN=dev-local-token +make run # or: PYTHONPATH=src ../state-hub/.venv/bin/python -m qonto_assistant.main +``` + +Then, using only the URL and `Authorization: Bearer dev-local-token` header +from the snippet above, connect with the `mcp` Python SDK's client (or any +MCP Inspector-style tool): + +```python +import asyncio +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +async def main(): + async with streamablehttp_client( + "http://127.0.0.1:8080/mcp", + headers={ + "Authorization": "Bearer dev-local-token", + "X-Actor-ID": "local-operator", + "X-Tenant-ID": "binky", + }, + ) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + print([tool.name for tool in (await session.list_tools()).tools]) + print((await session.call_tool("qonto_org_summary", {})).structuredContent) + +asyncio.run(main()) +``` + +A request without the `Authorization` header (or with the wrong token) gets +a `401 {"error_code": "unauthorized"}` before it ever reaches a tool. + +## Related + +- `docs/operator-runbook.md` — REST operator runbook (credential sources, + fixture mode, `/v1/*` endpoints) +- `specs/ArchitectureBlueprint.md` §6 Phase 2 — the plan this doc implements +- `workplans/QONTO-WP-0003-mcp-surface.md` — Phase 2 workplan and task status diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 5318555..4c20435 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -162,6 +162,13 @@ PY Use `window_days=31` for a recent-activity view. Use `window_days=90` or `93` when recurring fixed-cost hints are required. +## MCP surface + +Phase 2 mounts a streamable-HTTP MCP adapter at `/mcp` on this same process, +sharing the policy kernel and audit layer above. See +`docs/mcp-integration.md` for the tool catalog, the auth model +(`QONTO_ASSISTANT_MCP_TOKEN`), and the shared client config snippet. + ## CostRunRate refresh path `binky-control` should consume `GET /v1/snapshot` and extract: diff --git a/src/qonto_assistant/app.py b/src/qonto_assistant/app.py index 7bfac9e..bdc5d07 100644 --- a/src/qonto_assistant/app.py +++ b/src/qonto_assistant/app.py @@ -16,6 +16,7 @@ from qonto_assistant.auth import actor_claims_from_request from qonto_assistant.config import Settings from qonto_assistant.credentials import build_credential_provider from qonto_assistant.errors import QontoAssistantError, UpstreamError +from qonto_assistant.mcp_auth import BearerTokenAuthMiddleware from qonto_assistant.mcp_server import create_mcp_server from qonto_assistant.policy import PolicyEngine from qonto_assistant.qonto_client import FixtureQontoClient, QontoClient @@ -42,6 +43,8 @@ def create_app( mcp_server = create_mcp_server(settings=settings, service=service) mcp_app = mcp_server.streamable_http_app() + if settings.mcp_auth_token: + mcp_app.add_middleware(BearerTokenAuthMiddleware, token=settings.mcp_auth_token) @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: diff --git a/src/qonto_assistant/config.py b/src/qonto_assistant/config.py index 53770a6..f391dfd 100644 --- a/src/qonto_assistant/config.py +++ b/src/qonto_assistant/config.py @@ -32,6 +32,7 @@ class Settings: openbao_path: str openbao_command: str openbao_timeout_seconds: float + mcp_auth_token: str | None host: str port: int @@ -69,6 +70,7 @@ class Settings: openbao_path=os.getenv("QONTO_OPENBAO_PATH", "tenants/binky/qonto-api"), openbao_command=os.getenv("QONTO_OPENBAO_COMMAND", "bao"), openbao_timeout_seconds=float(os.getenv("QONTO_OPENBAO_TIMEOUT_SECONDS", "5")), + mcp_auth_token=os.getenv("QONTO_ASSISTANT_MCP_TOKEN") or None, host=os.getenv("QONTO_ASSISTANT_HOST", "127.0.0.1"), port=int(os.getenv("QONTO_ASSISTANT_PORT", "8080")), ) diff --git a/src/qonto_assistant/mcp_auth.py b/src/qonto_assistant/mcp_auth.py new file mode 100644 index 0000000..0b04ca3 --- /dev/null +++ b/src/qonto_assistant/mcp_auth.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import hmac + +from starlette.requests import Headers +from starlette.responses import JSONResponse +from starlette.types import ASGIApp, Receive, Scope, Send + + +class BearerTokenAuthMiddleware: + """Workload auth gate for the MCP endpoint: static shared-secret bearer token. + + QONTO-WP-0003-T03 scope note: the blueprint's target is OIDC/workload + identity (specs/ArchitectureBlueprint.md §4.7), but no OIDC issuer exists + in this fleet yet. A shared-secret bearer token is the "or similar" + workload-auth primitive that's actually deployable today -- it keeps + unauthenticated callers off the MCP surface entirely, same custody + principle as REST: this token is a service credential, never a bank + credential, and per-actor identity still comes from X-Actor-* headers + checked by the policy kernel. Upgrading to real OIDC/workload identity + federation is fleet-level follow-on work, not something this repo can + stand up alone. + """ + + def __init__(self, app: ASGIApp, *, token: str) -> None: + self.app = app + self.token = token + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + headers = Headers(scope=scope) + if not _token_matches(headers.get("authorization", ""), self.token): + response = JSONResponse( + {"error_code": "unauthorized", "detail": "Missing or invalid bearer token"}, + status_code=401, + headers={"WWW-Authenticate": "Bearer"}, + ) + await response(scope, receive, send) + return + + await self.app(scope, receive, send) + + +def _token_matches(authorization_header: str, expected_token: str) -> bool: + prefix = "Bearer " + if not authorization_header.startswith(prefix): + return False + presented = authorization_header[len(prefix) :] + return hmac.compare_digest(presented, expected_token) diff --git a/tests/test_api.py b/tests/test_api.py index 1f99315..8f3b2fe 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -38,6 +38,7 @@ def _settings() -> Settings: openbao_path="tenants/binky/qonto-api", openbao_command="bao", openbao_timeout_seconds=5, + mcp_auth_token=None, host="127.0.0.1", port=8080, ) diff --git a/tests/test_mcp_auth.py b/tests/test_mcp_auth.py new file mode 100644 index 0000000..e55609d --- /dev/null +++ b/tests/test_mcp_auth.py @@ -0,0 +1,35 @@ +from starlette.applications import Starlette +from starlette.responses import PlainTextResponse +from starlette.routing import Route +from starlette.testclient import TestClient + +from qonto_assistant.mcp_auth import BearerTokenAuthMiddleware + + +def _protected_app() -> Starlette: + async def ok(request): + return PlainTextResponse("ok") + + app = Starlette(routes=[Route("/", ok)]) + app.add_middleware(BearerTokenAuthMiddleware, token="secret-token") + return app + + +def test_bearer_auth_rejects_missing_header() -> None: + client = TestClient(_protected_app()) + response = client.get("/") + assert response.status_code == 401 + assert response.json()["error_code"] == "unauthorized" + + +def test_bearer_auth_rejects_wrong_token() -> None: + client = TestClient(_protected_app()) + response = client.get("/", headers={"Authorization": "Bearer wrong-token"}) + assert response.status_code == 401 + + +def test_bearer_auth_accepts_matching_token() -> None: + client = TestClient(_protected_app()) + response = client.get("/", headers={"Authorization": "Bearer secret-token"}) + assert response.status_code == 200 + assert response.text == "ok" diff --git a/workplans/QONTO-WP-0003-mcp-surface.md b/workplans/QONTO-WP-0003-mcp-surface.md index 90018b7..6a58bd9 100644 --- a/workplans/QONTO-WP-0003-mcp-surface.md +++ b/workplans/QONTO-WP-0003-mcp-surface.md @@ -122,7 +122,7 @@ REST. `pytest` → `25 passed`; `python3 -m compileall src tests scripts`. ```task id: QONTO-WP-0003-T03 -status: todo +status: done priority: high state_hub_task_id: "ad43b09a-ffa5-4df0-8999-bfcef1f11732" ``` @@ -137,6 +137,40 @@ Done when: the snippet is in `docs/operator-runbook.md` (or a new `docs/mcp-integration.md`), and a local smoke connects a real MCP client against the running adapter using only that snippet. +**Done 2026-07-23 — with a scoped gap, called out explicitly:** No OIDC +issuer exists anywhere in this fleet yet, so standing up real +OIDC/workload-identity federation isn't something this repo can do alone — +implementing it would mean pointing `FastMCP`'s OAuth Protected Resource +flow at an issuer URL that doesn't serve real metadata, which is worse than +not having it. Shipped the deployable primitive instead: a shared-secret +bearer token (`QONTO_ASSISTANT_MCP_TOKEN`), enforced by +`src/qonto_assistant/mcp_auth.py::BearerTokenAuthMiddleware` in front of +`/mcp` only (REST untouched). Unset by default (fixture/local smoke stays +credential-free); when set, every non-matching or missing +`Authorization: Bearer ` gets `401 {"error_code": "unauthorized"}` +before reaching any tool or the policy kernel. Constant-time comparison +(`hmac.compare_digest`). This token is a **service credential, not a bank +credential** — never logged, never reaches Qonto. Per-actor identity is +still the self-asserted `X-Actor-*` header convention (not yet +cryptographically bound to the bearer token — that binding is Phase 3's +flex-auth resource `finance.qonto.read`, not this task). + +Added `docs/mcp-integration.md`: tool table, the two-layer auth model +explained (workload auth today vs. the OIDC target and why it's deferred), +and the one shared `{"mcpServers": {...}}` config snippet (`url` + `headers` +— the convention shared by Claude Code, Claude Desktop, Cursor, and +Codex/Grok-style harnesses for remote streamable-HTTP MCP servers). + +Verified live, exactly as the doc's smoke section describes: started the +service with `QONTO_ASSISTANT_MCP_TOKEN` set and `QONTO_FIXTURE_DIR` (no +real Qonto credentials); a request with no `Authorization` header never +reached a tool; a request with the wrong token got `401 Unauthorized` in the +server log; a request built from *only* the doc's snippet (URL + the two +headers) listed tools and called `qonto_org_summary` successfully, with +`X-Actor-ID` flowing into the audit event as before. New +`tests/test_mcp_auth.py` covers missing/wrong/matching token cases directly. +`pytest` → `28 passed`; `python3 -m compileall src tests scripts`. + ## Task: agent-harness tool profile `finance-qonto-read` ```task