qonto-assistant/docs/mcp-integration.md
tegwick aa28ef353a QONTO-WP-0004-T03: verify key-cape IAM Profile tokens
Replaces the interim shared-secret bearer token's role as the identity
boundary with real key-cape JWKS-based verification, closing the gap
docs/mcp-integration.md called out explicitly ("no OIDC issuer exists
in this fleet yet") -- key-cape's /jwks is a standard RS256 endpoint
and needed no key-cape-side work to consume.

KeyCapeTokenVerifier fetches and caches signing keys over httpx
(matching FlexAuthCheckClient's pattern elsewhere in this codebase),
validates iss/aud/exp and the IAM Profile v0.3 required claims, and
derives ActorClaims from the token (tenant, scopes, and a lane
inferred from the roles claim). Wired into auth.actor_claims_from_headers,
the single seam both REST and MCP already used -- a verified bearer
token now takes precedence over self-asserted X-Actor-* headers, and
can be made mandatory via QONTO_KEY_CAPE_REQUIRED once real tokens are
issued to callers. Off by default (no QONTO_KEY_CAPE_JWKS_URL set) so
existing deployments are unaffected until configured.

The QONTO_ASSISTANT_MCP_TOKEN shared secret remains as a documented
local-dev/legacy fallback, not the auth boundary going forward.

Verified: 13 new tests (test_key_cape_auth.py) covering valid/expired/
wrong-audience/wrong-issuer/missing-claim/unknown-key/rotated-key
tokens plus the auth.py precedence and required-vs-optional
integration paths, using a real generated RSA keypair and JWKS served
over httpx.MockTransport. Full suite -> 52 passed; REST and MCP smokes
both still pass against fixtures; compileall clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:10:41 +02:00

9.4 KiB

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 <jwt> 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):

{
  "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):

"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):

# instance manifest (agent-harness side)
tool_profile: finance-qonto-read
// 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.

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):

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.

  • 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