qonto-assistant/workplans/QONTO-WP-0003-mcp-surface.md

311 lines
15 KiB
Markdown
Raw Normal View History

---
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 <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: todo
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`.