qonto-assistant/workplans/QONTO-WP-0003-mcp-surface.md
tegwick b4b1dc1c7b QONTO-WP-0003-T02: MCP tool catalog on the shared capability core
Add qonto_org_summary, qonto_list_transactions, and qonto_cost_run_rate_hints
MCP tools, all routed through CapabilityService with protocol="mcp" -- same
PolicyEngine.decide() path as REST, same deny-reason vocabulary. Skip
snapshot_bundle as an MCP tool (REST already covers the composite read; not
a separate privilege).

CapabilityService now threads protocol through _execute/_emit_audit instead
of hardcoding "rest". cost_run_rate_hints gets its own service method since
it's an independent policy capability, not only a snapshot sub-field.

Actor identity reuses REST's X-Actor-* header convention via a shared
auth.actor_claims_from_headers(), read from the MCP Context's request when
present. Fixed streamable_http_path defaulting to "/mcp", which doubled to
"/mcp/mcp" once mounted under the "/mcp" prefix.

Verified end-to-end with the mcp SDK's streamablehttp_client against the
live fixture-backed server: tool list, allow/deny paths, and X-Actor-ID
flowing through to the audit log exactly like REST.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 21:48:09 +02:00

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

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

id: QONTO-WP-0003-T04
status: todo
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.

Task: Unified audit schema across REST and MCP

id: QONTO-WP-0003-T05
status: todo
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.

Task: MCP smoke path + operator runbook update

id: QONTO-WP-0003-T06
status: todo
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.

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.