diff --git a/pyproject.toml b/pyproject.toml index c23844d..4b2b4ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ authors = [{ name = "Coulomb" }] dependencies = [ "fastapi>=0.115,<1.0", "httpx>=0.27,<1.0", + "mcp>=1.9,<2.0", "PyYAML>=6.0,<7.0", "uvicorn[standard]>=0.30,<1.0", ] @@ -20,6 +21,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=8.2,<9.0", + "pytest-asyncio>=0.24,<1.0", "ruff>=0.6,<1.0", ] @@ -39,6 +41,7 @@ addopts = [ "--disable-warnings", "--tb=short", ] +asyncio_mode = "auto" [tool.ruff] line-length = 100 diff --git a/src/qonto_assistant/app.py b/src/qonto_assistant/app.py index f0115e0..9a2b88f 100644 --- a/src/qonto_assistant/app.py +++ b/src/qonto_assistant/app.py @@ -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 diff --git a/src/qonto_assistant/mcp_server.py b/src/qonto_assistant/mcp_server.py new file mode 100644 index 0000000..96a0724 --- /dev/null +++ b/src/qonto_assistant/mcp_server.py @@ -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() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..3e8ab0a --- /dev/null +++ b/tests/test_mcp_server.py @@ -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 diff --git a/tests/test_policy.py b/tests/test_policy.py index e1dc7c4..d8068b9 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -1,5 +1,7 @@ from pathlib import Path +import pytest + from qonto_assistant.contracts import ActorClaims, CapabilityRequest 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) assert decision.allowed is False 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" diff --git a/workplans/QONTO-WP-0003-mcp-surface.md b/workplans/QONTO-WP-0003-mcp-surface.md index 02a259e..c3c11ca 100644 --- a/workplans/QONTO-WP-0003-mcp-surface.md +++ b/workplans/QONTO-WP-0003-mcp-surface.md @@ -4,7 +4,7 @@ type: workplan title: "Phase 2 — MCP surface for all harnesses" domain: infotech repo: qonto-assistant -status: ready +status: active owner: codex topic_slug: the-custodian created: "2026-07-22" @@ -32,7 +32,7 @@ rejected). ```task id: QONTO-WP-0003-T01 -status: todo +status: done priority: high 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 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