QONTO-WP-0003-T01: MCP adapter skeleton on shared capability core

Add a streamable-HTTP MCP adapter (mcp_server.py, official FastMCP SDK)
mounted at /mcp in the existing FastAPI app, with a combined lifespan so
the MCP session manager starts/stops with the service. Ships one smoke
tool (qonto_ping, no bank call) — real capability tools land in T02.

Prove REST and MCP hit the identical policy path: PolicyEngine.decide()
never branches on request.protocol, verified by a parametrized test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-22 21:41:24 +02:00
parent dc3431cda0
commit d4656be310
6 changed files with 133 additions and 10 deletions

View file

@ -13,6 +13,7 @@ authors = [{ name = "Coulomb" }]
dependencies = [ dependencies = [
"fastapi>=0.115,<1.0", "fastapi>=0.115,<1.0",
"httpx>=0.27,<1.0", "httpx>=0.27,<1.0",
"mcp>=1.9,<2.0",
"PyYAML>=6.0,<7.0", "PyYAML>=6.0,<7.0",
"uvicorn[standard]>=0.30,<1.0", "uvicorn[standard]>=0.30,<1.0",
] ]
@ -20,6 +21,7 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=8.2,<9.0", "pytest>=8.2,<9.0",
"pytest-asyncio>=0.24,<1.0",
"ruff>=0.6,<1.0", "ruff>=0.6,<1.0",
] ]
@ -39,6 +41,7 @@ addopts = [
"--disable-warnings", "--disable-warnings",
"--tb=short", "--tb=short",
] ]
asyncio_mode = "auto"
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import AsyncIterator, Callable
from contextlib import suppress from contextlib import AsyncExitStack, asynccontextmanager, suppress
import logging import logging
from uuid import uuid4 from uuid import uuid4
@ -16,6 +16,7 @@ from qonto_assistant.auth import actor_claims_from_request
from qonto_assistant.config import Settings from qonto_assistant.config import Settings
from qonto_assistant.credentials import build_credential_provider from qonto_assistant.credentials import build_credential_provider
from qonto_assistant.errors import QontoAssistantError, UpstreamError from qonto_assistant.errors import QontoAssistantError, UpstreamError
from qonto_assistant.mcp_server import create_mcp_server
from qonto_assistant.policy import PolicyEngine from qonto_assistant.policy import PolicyEngine
from qonto_assistant.qonto_client import FixtureQontoClient, QontoClient from qonto_assistant.qonto_client import FixtureQontoClient, QontoClient
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
@ -39,9 +40,21 @@ def create_app(
concurrency_limiter=concurrency_limiter, concurrency_limiter=concurrency_limiter,
) )
app = FastAPI(title="qonto-assistant", version=__version__) mcp_server = create_mcp_server(settings=settings)
mcp_app = mcp_server.streamable_http_app()
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp_app.router.lifespan_context(mcp_app))
yield
with suppress(Exception):
service.client.close()
app = FastAPI(title="qonto-assistant", version=__version__, lifespan=lifespan)
app.state.settings = settings app.state.settings = settings
app.state.service = service app.state.service = service
app.mount("/mcp", mcp_app)
@app.exception_handler(QontoAssistantError) @app.exception_handler(QontoAssistantError)
def handle_qonto_error(_: Request, exc: QontoAssistantError) -> JSONResponse: def handle_qonto_error(_: Request, exc: QontoAssistantError) -> JSONResponse:
@ -108,11 +121,6 @@ def create_app(
) )
return JSONResponse(content=payload, headers={"X-Request-ID": request_id}) return JSONResponse(content=payload, headers={"X-Request-ID": request_id})
@app.on_event("shutdown")
def shutdown_event() -> None:
with suppress(Exception):
service.client.close()
return app return app

View file

@ -0,0 +1,37 @@
from __future__ import annotations
from mcp.server.fastmcp import FastMCP
from starlette.applications import Starlette
from qonto_assistant import __version__
from qonto_assistant.config import Settings
def create_mcp_server(*, settings: Settings) -> FastMCP:
"""Build the MCP adapter on the same capability core as the REST surface.
Phase 2 (QONTO-WP-0003) scope note: this skeleton exposes only a smoke
tool. Real read tools (`qonto_org_summary`, `qonto_list_transactions`,
`qonto_cost_run_rate_hints`) land in QONTO-WP-0003-T02, routed through the
same `CapabilityRequest` -> `PolicyEngine.decide()` path REST already
uses -- no policy fork between transports.
"""
server = FastMCP(
name=settings.service_name,
instructions=(
"Governed read-only Qonto finance awareness for Binky. "
"No spend, transfer, card, or volume-cost tools are exposed here."
),
stateless_http=True,
)
@server.tool()
def qonto_ping() -> dict[str, str]:
"""Smoke tool confirming the MCP adapter is reachable. No bank call."""
return {"status": "ok", "service": settings.service_name, "version": __version__}
return server
def mcp_asgi_app(*, settings: Settings) -> Starlette:
return create_mcp_server(settings=settings).streamable_http_app()

25
tests/test_mcp_server.py Normal file
View file

@ -0,0 +1,25 @@
import pytest
from qonto_assistant.config import Settings
from qonto_assistant.mcp_server import create_mcp_server
def _settings() -> Settings:
settings = Settings.from_env()
return settings
@pytest.mark.asyncio
async def test_mcp_server_starts_and_lists_smoke_tool() -> None:
server = create_mcp_server(settings=_settings())
tools = await server.list_tools()
tool_names = {tool.name for tool in tools}
assert "qonto_ping" in tool_names
@pytest.mark.asyncio
async def test_mcp_server_smoke_tool_returns_no_bank_call() -> None:
server = create_mcp_server(settings=_settings())
result = await server.call_tool("qonto_ping", {})
payload = result[1] if isinstance(result, tuple) else result
assert payload is not None

View file

@ -1,5 +1,7 @@
from pathlib import Path from pathlib import Path
import pytest
from qonto_assistant.contracts import ActorClaims, CapabilityRequest from qonto_assistant.contracts import ActorClaims, CapabilityRequest
from qonto_assistant.policy import PolicyEngine from qonto_assistant.policy import PolicyEngine
@ -85,3 +87,34 @@ def test_policy_enforces_scope_when_enabled() -> None:
decision = _policy(enforce_scope=True).decide(request) decision = _policy(enforce_scope=True).decide(request)
assert decision.allowed is False assert decision.allowed is False
assert decision.reason == "authz_denied" assert decision.reason == "authz_denied"
@pytest.mark.parametrize("protocol", ["rest", "mcp"])
def test_policy_decision_identical_across_protocols(protocol: str) -> None:
"""QONTO-WP-0003-T01: REST and MCP must hit the identical policy path.
The decision for a known-deny case (a spend-shaped capability id) must
not depend on which transport originated the request.
"""
claims = ActorClaims(
actor_id="agent-1",
tenant_id="binky",
lane="green",
scopes=frozenset({"finance.qonto.read"}),
)
request = CapabilityRequest(
capability_id="list_transactions",
tenant_id="binky",
actor_claims=claims,
resource_scope="test",
request_args={
"page": 1,
"page_size": 50,
"window_days": 31,
"requested_action": "transfer_funds",
},
protocol=protocol,
)
decision = _policy().decide(request)
assert decision.allowed is False
assert decision.reason == "spend"

View file

@ -4,7 +4,7 @@ type: workplan
title: "Phase 2 — MCP surface for all harnesses" title: "Phase 2 — MCP surface for all harnesses"
domain: infotech domain: infotech
repo: qonto-assistant repo: qonto-assistant
status: ready status: active
owner: codex owner: codex
topic_slug: the-custodian topic_slug: the-custodian
created: "2026-07-22" created: "2026-07-22"
@ -32,7 +32,7 @@ rejected).
```task ```task
id: QONTO-WP-0003-T01 id: QONTO-WP-0003-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "b899ea70-13d4-4b70-8b45-b602359ab90b" state_hub_task_id: "b899ea70-13d4-4b70-8b45-b602359ab90b"
``` ```
@ -49,6 +49,23 @@ Done when: adapter starts, exposes an empty/smoke tool list, and a manual
path as the REST equivalent (e.g. shared unit test parametrized over both path as the REST equivalent (e.g. shared unit test parametrized over both
protocols). 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: MCP tool catalog mapped to allowed capabilities
```task ```task