qonto-assistant/docs/mcp-integration.md

204 lines
8.5 KiB
Markdown
Raw Normal View History

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