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>
11 KiB
| id | type | title | domain | repo | status | owner | topic_slug | created | updated | state_hub_workstream_id |
|---|---|---|---|---|---|---|---|---|---|---|
| QONTO-WP-0003 | workplan | Phase 2 — MCP surface for all harnesses | infotech | qonto-assistant | active | codex | the-custodian | 2026-07-22 | 2026-07-22 | 6ad0e906-5fbe-4b9e-8307-0abfc5c4a60a |
Phase 2 — MCP surface for all harnesses
Execute Phase 2 of specs/ArchitectureBlueprint.md §6: add a
streamable-HTTP MCP adapter on top of the same protocol-neutral capability
core and decide(request, claims) -> Allow|Deny policy kernel that Phase 1's
REST surface already uses. No new capabilities and no policy fork — MCP is a
second transport onto the existing allow-listed reads
(org_summary, list_transactions, cost_run_rate_hints, optionally
snapshot_bundle). Spend/volume-cost/credential-exfil stay hard-denied.
Depends on: QONTO-WP-0002 (done) for the capability core, policy kernel,
Qonto client, and audit layer this phase reuses. Non-goals: flex-auth
resource scopes and multi-tenant path layout (Phase 3/4); vendor
qonto-mcp-server as an internal backend (blueprint §4.3/§5.1 — explicitly
rejected).
Task: MCP adapter skeleton on the shared capability core
id: QONTO-WP-0003-T01
status: done
priority: high
state_hub_task_id: "b899ea70-13d4-4b70-8b45-b602359ab90b"
Pick an MCP server library/transport for the Python 3.12 stack (streamable-HTTP,
per blueprint §6 Phase 2.1 — not stdio-only). Scaffold the adapter under
src/qonto_assistant/ alongside the existing REST adapter, routing every tool
call through the same CapabilityRequest → decide() path used by REST
(protocol: mcp on the request). Do not duplicate policy logic in the MCP
layer.
Done when: adapter starts, exposes an empty/smoke tool list, and a manual
decide() call for a known-deny case is proven to hit the identical policy
path as the REST equivalent (e.g. shared unit test parametrized over both
protocols).
Done 2026-07-22: Picked the official mcp Python SDK (FastMCP,
streamable-HTTP transport, stateless_http=True) — added as a dependency in
pyproject.toml. src/qonto_assistant/mcp_server.py builds the adapter with
one smoke tool (qonto_ping, no bank call). app.py mounts it at /mcp with
a combined FastAPI lifespan (AsyncExitStack entering the MCP session
manager's lifespan) so the adapter starts/stops with the service — verified
live (StreamableHTTP session manager started/shutting down in logs,
/v1/health still 200, /mcp reachable). tests/test_policy.py adds
test_policy_decision_identical_across_protocols, parametrized over
protocol="rest"/"mcp", proving PolicyEngine.decide() is transport-
agnostic (the protocol field is carried on CapabilityRequest but never
branches policy logic). tests/test_mcp_server.py proves the adapter starts
and lists/calls the smoke tool. pytest-asyncio added as a dev dependency
(asyncio_mode = "auto"). Verified: pytest → 20 passed;
python3 -m compileall src tests scripts. No capability tools yet — that's
QONTO-WP-0003-T02.
Task: MCP tool catalog mapped to allowed capabilities
id: QONTO-WP-0003-T02
status: done
priority: high
state_hub_task_id: "f9e0d403-d6ae-4800-8d42-847c3d70827f"
Implement MCP tools per the capability map in specs/ArchitectureBlueprint.md
§5:
| Internal capability id | MCP tool name |
|---|---|
org_summary |
qonto_org_summary |
list_transactions |
qonto_list_transactions |
cost_run_rate_hints |
qonto_cost_run_rate_hints |
snapshot_bundle |
optional — only if it stays a pure orchestrator over already-allowed reads, not a bigger export |
Tool names may differ from REST paths but must resolve to the same internal
capability id and go through the same deny-reason vocabulary (authz, tenant
scope, argument constraint, credential exfil). No find_counterparties or any
write/spend-shaped tool — those stay hard-denied per Phase 1 policy.
Done when: unit tests cover allow paths for each tool and deny paths for out-of-catalog / spend-shaped tool names, mirroring the REST policy tests from QONTO-WP-0002-T02.
Done 2026-07-22: Implemented qonto_org_summary, qonto_list_transactions,
and qonto_cost_run_rate_hints in mcp_server.py, all routed through
CapabilityService with protocol="mcp" (skipped snapshot_bundle — REST
already covers the composite read and it isn't a separate privilege).
CapabilityService now threads protocol: ProtocolName through _execute
and _emit_audit instead of hardcoding "rest"; added
get_cost_run_rate_hints() + _build_cost_run_rate_hints_payload() since
cost_run_rate_hints is its own policy capability, not only a snapshot
sub-field. Actor identity uses the same X-Actor-* header convention as
REST — auth.py now exposes a shared actor_claims_from_headers(), read
from the MCP Context's underlying Starlette request when present, falling
back to defaults for stdio/no-request-context callers. Fixed a routing bug:
FastMCP's default streamable_http_path="/mcp" plus mounting at /mcp
doubled to /mcp/mcp — set streamable_http_path="/" on the sub-app instead.
Verified: tests/test_mcp_server.py covers tool listing, an allow path
(qonto_org_summary, asserts redacted output + protocol: "mcp" audit
event), a deny path (qonto_list_transactions oversized page_size →
ToolError, deny_reason: "arg_constraint"), and qonto_cost_run_rate_hints.
Also ran a real end-to-end check with the mcp SDK's streamablehttp_client
against the live server (fixture-backed): tool list, qonto_org_summary call,
and confirmed X-Actor-ID header flows through to the audit log exactly like
REST. pytest → 25 passed; python3 -m compileall src tests scripts.
Task: Client auth and one shared config snippet
id: QONTO-WP-0003-T03
status: done
priority: high
state_hub_task_id: "ad43b09a-ffa5-4df0-8999-bfcef1f11732"
Wire OIDC/workload auth for the MCP endpoint — no bank secrets ever reach the client, matching the REST surface's custody model (blueprint §4.1, §7). Produce a single documented client config snippet usable across Claude Code, Codex, Cursor, and Grok-style harnesses: URL + auth only, no per-harness variants.
Done when: the snippet is in docs/operator-runbook.md (or a new
docs/mcp-integration.md), and a local smoke connects a real MCP client
against the running adapter using only that snippet.
Done 2026-07-23 — with a scoped gap, called out explicitly: No OIDC
issuer exists anywhere in this fleet yet, so standing up real
OIDC/workload-identity federation isn't something this repo can do alone —
implementing it would mean pointing FastMCP's OAuth Protected Resource
flow at an issuer URL that doesn't serve real metadata, which is worse than
not having it. Shipped the deployable primitive instead: a shared-secret
bearer token (QONTO_ASSISTANT_MCP_TOKEN), enforced by
src/qonto_assistant/mcp_auth.py::BearerTokenAuthMiddleware in front of
/mcp only (REST untouched). Unset by default (fixture/local smoke stays
credential-free); when set, every non-matching or missing
Authorization: Bearer <token> gets 401 {"error_code": "unauthorized"}
before reaching any tool or the policy kernel. Constant-time comparison
(hmac.compare_digest). This token is a service credential, not a bank
credential — never logged, never reaches Qonto. Per-actor identity is
still the self-asserted X-Actor-* header convention (not yet
cryptographically bound to the bearer token — that binding is Phase 3's
flex-auth resource finance.qonto.read, not this task).
Added docs/mcp-integration.md: tool table, the two-layer auth model
explained (workload auth today vs. the OIDC target and why it's deferred),
and the one shared {"mcpServers": {...}} config snippet (url + headers
— the convention shared by Claude Code, Claude Desktop, Cursor, and
Codex/Grok-style harnesses for remote streamable-HTTP MCP servers).
Verified live, exactly as the doc's smoke section describes: started the
service with QONTO_ASSISTANT_MCP_TOKEN set and QONTO_FIXTURE_DIR (no
real Qonto credentials); a request with no Authorization header never
reached a tool; a request with the wrong token got 401 Unauthorized in the
server log; a request built from only the doc's snippet (URL + the two
headers) listed tools and called qonto_org_summary successfully, with
X-Actor-ID flowing into the audit event as before. New
tests/test_mcp_auth.py covers missing/wrong/matching token cases directly.
pytest → 28 passed; python3 -m compileall src tests scripts.
Task: agent-harness tool profile finance-qonto-read
id: QONTO-WP-0003-T04
status: todo
priority: medium
state_hub_task_id: "c6805812-8419-4200-b4ad-a048a541b3be"
Register the finance-qonto-read tool profile (blueprint §6 Phase 2.3) so
harness-side configuration grants this MCP surface only to the intended
assistant context, not blanket per-harness key sprawl. Coordinate with
whichever agent-harness config surface is authoritative for profile
definitions (outside this repo) — this task covers qonto-assistant's side:
documenting the profile contract (tool names, required scopes) it expects
callers to present.
Done when: profile contract is documented and referenced from the MCP integration doc; at least one harness config example uses it end-to-end.
Task: Unified audit schema across REST and MCP
id: QONTO-WP-0003-T05
status: todo
priority: medium
state_hub_task_id: "b7d70c3e-fe55-47d9-9180-cd4c0bedc31a"
Confirm MCP tool calls emit the exact same structured audit event shape as REST calls (actor, capability, decision, deny_reason, latency, upstream HTTP status, policy version — no secret fields), per blueprint §6 Phase 2.4. State Hub continues to receive operator-level progress notes, not per-call hot-path events — do not wire per-invocation State Hub writes.
Done when: a test asserts REST and MCP audit records for the same capability call are schema-identical modulo protocol field.
Task: MCP smoke path + operator runbook update
id: QONTO-WP-0003-T06
status: todo
priority: medium
state_hub_task_id: "1093ec79-eb16-4797-8c9c-5423a925db77"
Add an MCP-equivalent of scripts/smoke_rest_api.py: start the service,
connect over streamable-HTTP, exercise each allowed tool against fixture data
(QONTO_FIXTURE_DIR), and confirm deny behavior for an out-of-catalog tool.
Extend docs/operator-runbook.md with MCP start/verify instructions
alongside the existing REST section.
Done when: smoke script passes locally against fixtures without real Qonto credentials, matching the Phase 1 REST smoke pattern.
Task: Closure review
id: QONTO-WP-0003-T07
status: todo
priority: low
state_hub_task_id: "1dc90a1e-0dcc-40c1-80c9-c7403a348db0"
Mark workplan finished when T01–T06 are done; confirm REST and MCP surfaces
enforce identical policy outcomes end to end. Note Phase 3 seed (flex-auth
resource finance.qonto.read, graduated quotas, redaction profiles) in
closure. Run statehub fix-consistency.