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>
215 lines
9.4 KiB
Markdown
215 lines
9.4 KiB
Markdown
# 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):
|
|
|
|
```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
|