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>
This commit is contained in:
parent
e1e47ae304
commit
d2ffd372b5
9 changed files with 259 additions and 5 deletions
119
docs/mcp-integration.md
Normal file
119
docs/mcp-integration.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# 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.
|
||||
|
||||
## Local smoke: start with auth enabled, connect with only this snippet
|
||||
|
||||
```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
|
||||
|
|
@ -162,6 +162,13 @@ PY
|
|||
Use `window_days=31` for a recent-activity view. Use `window_days=90` or `93`
|
||||
when recurring fixed-cost hints are required.
|
||||
|
||||
## MCP surface
|
||||
|
||||
Phase 2 mounts a streamable-HTTP MCP adapter at `/mcp` on this same process,
|
||||
sharing the policy kernel and audit layer above. See
|
||||
`docs/mcp-integration.md` for the tool catalog, the auth model
|
||||
(`QONTO_ASSISTANT_MCP_TOKEN`), and the shared client config snippet.
|
||||
|
||||
## CostRunRate refresh path
|
||||
|
||||
`binky-control` should consume `GET /v1/snapshot` and extract:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue