qonto-assistant/workplans/QONTO-WP-0003-mcp-surface.md
tegwick ad500121f4 QONTO-WP-0003-T07: close out Phase 2 MCP workplan
T01-T06 all done. Final end-to-end confirmation before closing: pytest ->
31 passed; both scripts/smoke_rest_api.py and scripts/smoke_mcp.py exit 0
against fixtures in the same run; compileall clean.

REST/MCP policy-outcome parity is proven by test, not just asserted:
test_policy_decision_identical_across_protocols (decide() never branches on
transport) plus test_audit_parity.py (identical audit shape for the same
capability call over both transports, allow and deny paths).

Workplan status -> finished. Closure note records what shipped, the three
known gaps carried forward (OIDC federation, header-based actor identity,
unregistered agent-harness profile), and the Phase 3 seed. README updated
to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 13:56:06 +02:00

356 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
id: QONTO-WP-0003
type: workplan
title: "Phase 2 — MCP surface for all harnesses"
domain: infotech
repo: qonto-assistant
status: finished
owner: codex
topic_slug: the-custodian
created: "2026-07-22"
updated: "2026-07-23"
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 <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`
```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: done
priority: low
state_hub_task_id: "1dc90a1e-0dcc-40c1-80c9-c7403a348db0"
```
Mark workplan finished when T01T06 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`.
**Closed 2026-07-23.** T01T06 all done. Final end-to-end confirmation
before closing: `pytest``31 passed`; `scripts/smoke_rest_api.py` and
`scripts/smoke_mcp.py` both exit `0` against fixtures in the same run;
`python3 -m compileall src tests scripts` clean.
**Identical policy outcomes, confirmed two ways, not asserted:**
`tests/test_policy.py::test_policy_decision_identical_across_protocols`
(T01) proves `PolicyEngine.decide()` never branches on `protocol` at the
unit level, and `tests/test_audit_parity.py` (T05) proves the resulting
audit events are schema-identical for the same capability call over both
transports, for both an allow and a deny path. Both REST and MCP call
through the same `CapabilityService._execute``PolicyEngine.decide()`
path — there is structurally no second policy implementation to drift.
**What Phase 2 shipped:** streamable-HTTP MCP adapter mounted at `/mcp`
(T01); three capability tools — `qonto_org_summary`,
`qonto_list_transactions`, `qonto_cost_run_rate_hints` — mapped 1:1 to the
existing REST-allowed capabilities, `snapshot_bundle` deliberately not
mirrored as it isn't a separate privilege (T02); shared-secret bearer-token
workload auth (`QONTO_ASSISTANT_MCP_TOKEN`) plus one client config snippet
covering Claude Code/Desktop/Cursor/Codex-style harnesses, with the real
OIDC/workload-identity gap called out explicitly rather than faked (T03);
the qonto-assistant-side contract for agent-harness's `finance-qonto-read`
tool profile, checked against the real `agent_harness/profiles.py` schema
(T04); REST/MCP audit-schema parity pinned by test (T05); an MCP smoke
script exercising the full tool catalog, the auth gate, and the
out-of-catalog deny path against fixtures with no real credentials (T06).
**Known gaps carried forward, not closed by this workplan:**
- Real OIDC/workload-identity federation (no issuer exists in this fleet —
see `docs/mcp-integration.md`'s auth model section). The bearer token is
a deployable interim, not the blueprint's target state.
- Per-actor identity (`X-Actor-*` headers) is still self-asserted, not
cryptographically bound to the workload-auth token.
- `finance-qonto-read` is documented but not registered in
`agent-harness/agent_harness/profiles.py` — that's agent-harness's own
repo/workplan to pick up.
**Phase 3 seed** (`specs/ArchitectureBlueprint.md` §6 Phase 3, not started):
a flex-auth resource `finance.qonto.read` (+ later `.export`) to bind actor
identity to the workload-auth layer instead of self-asserted headers;
graduated quotas and differentiated response-redaction profiles; optional
Envoy/gateway mesh in front for multi-assistant deployments. The
`QONTO_ASSISTANT_REQUIRED_SCOPE`/`QONTO_ASSISTANT_ENFORCE_SCOPE` settings
already exist in `config.py` as the landing spot for that scope name.