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

@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Callable
from contextlib import suppress
from collections.abc import AsyncIterator, Callable
from contextlib import AsyncExitStack, asynccontextmanager, suppress
import logging
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.credentials import build_credential_provider
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.qonto_client import FixtureQontoClient, QontoClient
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
@ -39,9 +40,21 @@ def create_app(
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.service = service
app.mount("/mcp", mcp_app)
@app.exception_handler(QontoAssistantError)
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})
@app.on_event("shutdown")
def shutdown_event() -> None:
with suppress(Exception):
service.client.close()
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()