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>
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):
-
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 aKeyCapeTokenVerifieris built and wired into both REST and MCP (auth.actor_claims_from_headers). A verifiedAuthorization: Bearer <jwt>takes precedence over self-asserted headers; actor identity (tenant, scopes, and a lane derived from the profile'srolesclaim) comes from the verified token, not the caller's assertion. SetQONTO_KEY_CAPE_REQUIRED=trueto reject any request without a valid bearer token outright, once real key-cape tokens are actually being issued to callers — until then, leave itfalseso unconfigured deployments keep working during rollout. - Shared-secret bearer token (legacy/local-dev).
QONTO_ASSISTANT_MCP_TOKEN, checked byBearerTokenAuthMiddlewarein front of/mcponly (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.
- key-cape IAM Profile tokens (QONTO-WP-0004-T03, current target).
Set
-
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) — seesrc/qonto_assistant/key_cape_auth.py. Otherwise, the sameX-Actor-ID/X-Tenant-ID/X-Actor-Lane/X-Actor-Scopesheader 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 liveflex-authdecision onfinance.qonto.readis 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.
Related
docs/operator-runbook.md— REST operator runbook (credential sources, fixture mode,/v1/*endpoints)specs/ArchitectureBlueprint.md§6 Phase 2 — the plan this doc implementsworkplans/QONTO-WP-0003-mcp-surface.md— Phase 2 workplan and task status