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:
parent
dc3431cda0
commit
d4656be310
6 changed files with 133 additions and 10 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
37
src/qonto_assistant/mcp_server.py
Normal file
37
src/qonto_assistant/mcp_server.py
Normal 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
25
tests/test_mcp_server.py
Normal 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
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue