Add scripts/smoke_mcp.py, same shape as smoke_rest_api.py (random port, subprocess-launch against QONTO_FIXTURE_DIR, wait on /v1/health, assert, clean teardown), but goes further: generates a fresh QONTO_ASSISTANT_MCP_TOKEN per run and connects through it with the mcp SDK's streamablehttp_client, so the smoke exercises T03's bearer-token gate instead of bypassing it. Lists tools, calls all four, and confirms an out-of-catalog tool name comes back as a normal isError result through the real wire protocol rather than a crash. Extend docs/operator-runbook.md with a "One-command MCP smoke" section next to the REST one; cross-link from docs/mcp-integration.md's manual smoke walkthrough so the two don't drift. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
310 lines
15 KiB
Markdown
310 lines
15 KiB
Markdown
---
|
||
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 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`.
|