qonto-assistant/docs/mcp-integration.md
tegwick d2ffd372b5 QONTO-WP-0003-T03: MCP client auth + shared multi-harness config snippet
Gate /mcp with a shared-secret bearer token (QONTO_ASSISTANT_MCP_TOKEN,
mcp_auth.py::BearerTokenAuthMiddleware, constant-time compare, REST
untouched) since no OIDC issuer exists in this fleet yet -- pointing
FastMCP's OAuth Protected Resource flow at a non-existent issuer would be
worse than not having it. This token is a service credential, never a bank
credential; per-actor identity stays the existing X-Actor-* convention.

Add docs/mcp-integration.md: tool catalog, the two-layer auth model (workload
auth today vs. deferred OIDC target), and one shared {"mcpServers": {...}}
client config snippet (url + headers) usable across Claude Code, Claude
Desktop, Cursor, and Codex/Grok-style harnesses.

Verified live using only that snippet: unauthenticated and wrong-token
requests get 401 before reaching any tool; a request built from the
snippet's URL + headers lists tools and calls qonto_org_summary
successfully against the fixture-backed server.

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

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

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

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