qonto-assistant/docs/mcp-integration.md
tegwick 0dc001bf53 QONTO-WP-0003-T06: MCP smoke script + operator runbook update
Add scripts/smoke_mcp.py, same shape as smoke_rest_api.py (random port,
subprocess-launch against QONTO_FIXTURE_DIR, wait on /v1/health, assert,
clean teardown), but goes further: generates a fresh
QONTO_ASSISTANT_MCP_TOKEN per run and connects through it with the mcp
SDK's streamablehttp_client, so the smoke exercises T03's bearer-token gate
instead of bypassing it. Lists tools, calls all four, and confirms an
out-of-catalog tool name comes back as a normal isError result through the
real wire protocol rather than a crash.

Extend docs/operator-runbook.md with a "One-command MCP smoke" section next
to the REST one; cross-link from docs/mcp-integration.md's manual smoke
walkthrough so the two don't drift.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 11:04:39 +02:00

8.5 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.

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