qonto-assistant/workplans/QONTO-WP-0003-mcp-surface.md
tegwick 0dc001bf53 QONTO-WP-0003-T06: MCP smoke script + operator runbook update
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>
2026-07-23 11:04:39 +02:00

15 KiB
Raw Blame History

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 CapabilityRequestdecide() 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: pytest20 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_sizeToolError, 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. pytest25 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. pytest28 passed; python3 -m compileall src tests scripts.

Task: agent-harness tool profile finance-qonto-read

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. pytest28 passed (unchanged).

Task: Unified audit schema across REST and MCP

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_sizearg_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. pytest31 passed.

Task: MCP smoke path + operator runbook update

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. pytest31 passed (unchanged); python3 -m compileall src tests scripts.

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 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.