--- id: QONTO-WP-0003 type: workplan title: "Phase 2 — MCP surface for all harnesses" domain: infotech repo: qonto-assistant status: active owner: codex topic_slug: the-custodian created: "2026-07-22" updated: "2026-07-22" state_hub_workstream_id: "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 ```task 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 ```task 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 ```task 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 ` 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` ```task id: QONTO-WP-0003-T04 status: done 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. **Done 2026-07-23:** Checked `~/agent-harness` (ADR-001, `agent_harness/profiles.py`) directly rather than guessing the shape — `ToolProfile` is real: instances declare a profile by name, the harness resolves and enforces a hard `--allowedTools` allow-list, instances never enumerate tools. Today only `green-commit-only` and `blue-mail-triage` are registered, both CLI-only; no profile currently grants any MCP server. Added a **`finance-qonto-read`** section to `docs/mcp-integration.md` with the qonto-assistant-side contract: lane (`green`/`blue` only, never `red`), the exact `mcp__qonto-assistant__*` allowed-tools list (all four tools, nothing more), and the optional `finance.qonto.read` required scope. Included a ready-to-paste proposed `ToolProfile` entry matching agent-harness's actual `profiles.py` schema, explicitly marked as a proposal for agent-harness's own PR — **not applied to that repo by this task**, since registering it there is outside qonto-assistant's scope and a separate repo/workplan. Added the end-to-end example the done-criteria asks for: an instance manifest (`tool_profile: finance-qonto-read`) paired with the MCP server config the harness would inject for that session, showing the full path from profile name to the four allowed tool calls. No code changes — this task is qonto-assistant-side documentation only, per its own scope note. `pytest` → `28 passed` (unchanged). ## Task: Unified audit schema across REST and MCP ```task id: QONTO-WP-0003-T05 status: done 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. **Done 2026-07-23:** This was already true structurally since T02 (`CapabilityService._emit_audit` is the single audit call site for both transports), but it wasn't pinned down by a test — a future change could have special-cased one transport's shape without anything failing. Added `tests/test_audit_parity.py`: calls the same capability through `protocol="rest"` and `protocol="mcp"` for both an allow path (`org_summary`) and a deny path (`list_transactions` oversized `page_size` → `arg_constraint`), then asserts the two events have identical key sets and identical values on every field except `request_id`/`timestamp`/ `latency_ms` (expected to vary per call) and `protocol` (expected to differ by design). A third test asserts no audit event ever contains a secret-shaped field name (`api_key`, `authorization`, `token`, etc.) — belt-and-suspenders alongside `tests/test_audit.py`'s existing redaction test. Confirmed (`grep`) there is no State Hub coupling anywhere in `src/qonto_assistant/`: the only audit sink is `AuditLogger` (structured stdout JSON, or a test-injected `sink` callable) — no per-call State Hub write exists to accidentally wire up. `pytest` → `31 passed`. ## Task: MCP smoke path + operator runbook update ```task id: QONTO-WP-0003-T06 status: done 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. **Done 2026-07-23:** Added `scripts/smoke_mcp.py`, same shape as `scripts/smoke_rest_api.py` (random free port, subprocess-launch the service against `QONTO_FIXTURE_DIR`, wait on `/v1/health`, assert, tear down cleanly). Goes one step further than the REST smoke since MCP now has an auth layer REST doesn't: generates a fresh `QONTO_ASSISTANT_MCP_TOKEN` per run and connects with the `mcp` SDK's `streamablehttp_client` using it, so the smoke exercises T03's bearer-token gate rather than bypassing it. Lists tools and asserts the exact expected catalog; calls `qonto_ping`, `qonto_org_summary`, `qonto_list_transactions`, and `qonto_cost_run_rate_hints` against fixture data with the same balance/ recurring-debit assertions the REST smoke makes; calls a never-registered tool name (`qonto_transfer_funds`) and asserts it comes back as a normal `isError` result through the real wire protocol, not a crash — confirming the low-level MCP protocol handler rejects unknown tools cleanly rather than falling through to anything policy-adjacent. Extended `docs/operator-runbook.md` with a "One-command MCP smoke" section next to the existing REST one, and cross-linked it from `docs/mcp-integration.md`'s manual smoke walkthrough so the two don't drift out of sync. Verified: `python3 scripts/smoke_mcp.py --python ../state-hub/.venv/bin/python` exits `0` against fixtures, no real Qonto credentials. `pytest` → `31 passed` (unchanged); `python3 -m compileall src tests scripts`. ## Task: Closure review ```task 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`.