# 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?)** Two mechanisms now coexist: - **key-cape IAM Profile tokens (QONTO-WP-0004-T03, current target).** Set `QONTO_KEY_CAPE_JWKS_URL` (and, if not using the defaults, `QONTO_KEY_CAPE_ISSUER`/`QONTO_KEY_CAPE_AUDIENCE`) and a `KeyCapeTokenVerifier` is built and wired into both REST and MCP (`auth.actor_claims_from_headers`). A verified `Authorization: Bearer ` takes precedence over self-asserted headers; actor identity (tenant, scopes, and a lane derived from the profile's `roles` claim) comes from the verified token, not the caller's assertion. Set `QONTO_KEY_CAPE_REQUIRED=true` to reject any request without a valid bearer token outright, once real key-cape tokens are actually being issued to callers — until then, leave it `false` so unconfigured deployments keep working during rollout. - **Shared-secret bearer token (legacy/local-dev).** `QONTO_ASSISTANT_MCP_TOKEN`, checked by `BearerTokenAuthMiddleware` in front of `/mcp` only (REST has no equivalent — see below). If unset, the middleware is not installed. This predates the key-cape integration and should be treated as fixture/local-dev only, never the auth boundary for a deployment holding real credentials. 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?)** When a key-cape verifier is configured, actor identity comes from the verified token's claims (`sub` → actor id, `tenant` → tenant id, `roles` → lane, `scope`/`scp` → scopes) — see `src/qonto_assistant/key_cape_auth.py`. Otherwise, the same `X-Actor-ID` / `X-Tenant-ID` / `X-Actor-Lane` / `X-Actor-Scopes` header convention REST and MCP both use (`auth.actor_claims_from_headers`) applies, self-asserted and not cryptographically bound to anything — the gap that configuring key-cape closes. Tightening this further with a live `flex-auth` decision on `finance.qonto.read` is QONTO-WP-0004-T04. ## 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. ## Agent-harness tool profile: `finance-qonto-read` `agent-harness` (`~/agent-harness`, ADR-001) is the session runtime that coding-agent sessions actually run inside. It enforces a hard tool allow-list per session via a named `ToolProfile` (`agent_harness/profiles.py`) — instances declare a profile by name in their manifest; the harness resolves and enforces the allow-list; instances never enumerate tools themselves. Today `agent_harness/profiles.py` only registers CLI/session profiles (`green-commit-only`, `blue-mail-triage`) built from Claude Code `--allowedTools` strings — no profile currently grants any MCP server. This is **qonto-assistant's side only**: the contract a profile named `finance-qonto-read` must satisfy so a session granted it can reach this MCP surface and nothing else. Registering the profile itself is agent-harness's own workplan/repo — this is not applied here. **Contract:** | Field | Value | | --- | --- | | Profile name | `finance-qonto-read` | | Lane | `green` or `blue` only — never `red` (matches `specs/ArchitectureBlueprint.md` §4.6: reads are Green/Blue; plan/key/transfer changes are Red, and this surface never exposes those anyway) | | MCP server | the `qonto-assistant` entry from the client config snippet below, with the bearer token and `X-Actor-*` headers injected by the harness — never left for the instance to fill in | | Allowed tools | `mcp__qonto-assistant__qonto_ping`, `mcp__qonto-assistant__qonto_org_summary`, `mcp__qonto-assistant__qonto_list_transactions`, `mcp__qonto-assistant__qonto_cost_run_rate_hints` — the full catalog above, nothing more (no future write tool is ever silently included; adding one here always requires an explicit profile edit) | | Required scope (optional today) | `finance.qonto.read` — matches `QONTO_ASSISTANT_REQUIRED_SCOPE`; only enforced when the service sets `QONTO_ASSISTANT_ENFORCE_SCOPE=true` | **Proposed `agent_harness/profiles.py` entry** (for agent-harness's own PR, not applied by this task): ```python "finance-qonto-read": ToolProfile( name="finance-qonto-read", description=( "Read-only Qonto finance awareness via qonto-assistant MCP. " "No spend, transfer, card, or volume-cost tools." ), allowed_tools=( "mcp__qonto-assistant__qonto_ping," "mcp__qonto-assistant__qonto_org_summary," "mcp__qonto-assistant__qonto_list_transactions," "mcp__qonto-assistant__qonto_cost_run_rate_hints" ), lane="green", ), ``` **End-to-end example** — an agent-harness instance manifest declaring the profile, paired with the MCP server config the harness would inject for that session (the same shape as the shared snippet above, scoped to one profile): ```yaml # instance manifest (agent-harness side) tool_profile: finance-qonto-read ``` ```json // MCP server config the harness injects for a finance-qonto-read session { "mcpServers": { "qonto-assistant": { "url": "http://127.0.0.1:8080/mcp", "headers": { "Authorization": "Bearer ${QONTO_ASSISTANT_MCP_TOKEN}", "X-Actor-ID": "agent-harness:${INSTANCE_ID}", "X-Tenant-ID": "binky", "X-Actor-Lane": "green" } } } } ``` The instance never sees the bearer token or picks its own headers — the harness resolves the profile, injects this config, and the session's `--allowedTools` allow-list (from the profile) is the only thing standing between the model and which of the four tools it can call. Policy enforcement itself still happens inside qonto-assistant regardless of what the harness allows, per the shared `PolicyEngine.decide()` path. ## Local smoke: start with auth enabled, connect with only this snippet For a one-command version of everything below (random port, generated token, all four tools, plus the out-of-catalog deny check), see `scripts/smoke_mcp.py` in `docs/operator-runbook.md`. The manual walkthrough here is for when you want to see each step explicitly. ```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