Gate /mcp with a shared-secret bearer token (QONTO_ASSISTANT_MCP_TOKEN,
mcp_auth.py::BearerTokenAuthMiddleware, constant-time compare, REST
untouched) since no OIDC issuer exists in this fleet yet -- pointing
FastMCP's OAuth Protected Resource flow at a non-existent issuer would be
worse than not having it. This token is a service credential, never a bank
credential; per-actor identity stays the existing X-Actor-* convention.
Add docs/mcp-integration.md: tool catalog, the two-layer auth model (workload
auth today vs. deferred OIDC target), and one shared {"mcpServers": {...}}
client config snippet (url + headers) usable across Claude Code, Claude
Desktop, Cursor, and Codex/Grok-style harnesses.
Verified live using only that snippet: unauthenticated and wrong-token
requests get 401 before reaching any tool; a request built from the
snippet's URL + headers lists tools and calls qonto_org_summary
successfully against the fixture-backed server.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
186 lines
6.6 KiB
Python
186 lines
6.6 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncIterator, Callable
|
|
from contextlib import AsyncExitStack, asynccontextmanager, suppress
|
|
import logging
|
|
from uuid import uuid4
|
|
|
|
import uvicorn
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import JSONResponse
|
|
from starlette.concurrency import run_in_threadpool
|
|
|
|
from qonto_assistant import __version__
|
|
from qonto_assistant.audit import AuditLogger
|
|
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_auth import BearerTokenAuthMiddleware
|
|
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
|
|
from qonto_assistant.service import CapabilityService
|
|
|
|
|
|
def create_app(
|
|
*,
|
|
settings: Settings | None = None,
|
|
service: CapabilityService | None = None,
|
|
audit_logger: AuditLogger | None = None,
|
|
rate_limiter: RateLimiter | None = None,
|
|
concurrency_limiter: ConcurrencyLimiter | None = None,
|
|
) -> FastAPI:
|
|
settings = settings or Settings.from_env()
|
|
audit_logger = audit_logger or AuditLogger()
|
|
service = service or _build_service(
|
|
settings=settings,
|
|
audit_logger=audit_logger,
|
|
rate_limiter=rate_limiter,
|
|
concurrency_limiter=concurrency_limiter,
|
|
)
|
|
|
|
mcp_server = create_mcp_server(settings=settings, service=service)
|
|
mcp_app = mcp_server.streamable_http_app()
|
|
if settings.mcp_auth_token:
|
|
mcp_app.add_middleware(BearerTokenAuthMiddleware, token=settings.mcp_auth_token)
|
|
|
|
@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:
|
|
body = {"error_code": exc.error_code, "detail": exc.message}
|
|
if isinstance(exc, UpstreamError) and exc.upstream_status is not None:
|
|
body["upstream_status"] = exc.upstream_status
|
|
return JSONResponse(status_code=exc.status_code, content=body)
|
|
|
|
@app.get("/v1/health")
|
|
async def health() -> dict[str, str]:
|
|
return {
|
|
"status": "ok",
|
|
"service": settings.service_name,
|
|
"version": __version__,
|
|
"policy_file": str(settings.policy_file),
|
|
}
|
|
|
|
@app.get("/v1/accounts")
|
|
async def get_accounts(request: Request) -> JSONResponse:
|
|
claims = actor_claims_from_request(request, settings)
|
|
request_id = _request_id(request)
|
|
payload = await run_in_threadpool(service.get_accounts, claims=claims, request_id=request_id)
|
|
return JSONResponse(content=payload, headers={"X-Request-ID": request_id})
|
|
|
|
@app.get("/v1/transactions")
|
|
async def get_transactions(
|
|
request: Request,
|
|
account_slug: str | None = None,
|
|
page: int = 1,
|
|
page_size: int = 50,
|
|
window_days: int = 31,
|
|
status: str | None = "completed",
|
|
side: str | None = None,
|
|
) -> JSONResponse:
|
|
claims = actor_claims_from_request(request, settings)
|
|
request_id = _request_id(request)
|
|
payload = await run_in_threadpool(
|
|
service.list_transactions,
|
|
claims=claims,
|
|
request_id=request_id,
|
|
account_slug=account_slug,
|
|
page=page,
|
|
page_size=page_size,
|
|
window_days=window_days,
|
|
status=status,
|
|
side=side,
|
|
)
|
|
return JSONResponse(content=payload, headers={"X-Request-ID": request_id})
|
|
|
|
@app.get("/v1/snapshot")
|
|
async def get_snapshot(
|
|
request: Request,
|
|
window_days: int = 31,
|
|
page_size: int = 50,
|
|
) -> JSONResponse:
|
|
claims = actor_claims_from_request(request, settings)
|
|
request_id = _request_id(request)
|
|
payload = await run_in_threadpool(
|
|
service.get_snapshot,
|
|
claims=claims,
|
|
request_id=request_id,
|
|
window_days=window_days,
|
|
page_size=page_size,
|
|
)
|
|
return JSONResponse(content=payload, headers={"X-Request-ID": request_id})
|
|
|
|
return app
|
|
|
|
|
|
def _request_id(request: Request) -> str:
|
|
existing = getattr(request.state, "request_id", None)
|
|
if existing:
|
|
return existing
|
|
request.state.request_id = request.headers.get("x-request-id", str(uuid4()))
|
|
return request.state.request_id
|
|
|
|
|
|
def _build_service(
|
|
*,
|
|
settings: Settings,
|
|
audit_logger: AuditLogger,
|
|
rate_limiter: RateLimiter | None,
|
|
concurrency_limiter: ConcurrencyLimiter | None,
|
|
) -> CapabilityService:
|
|
policy = PolicyEngine.from_file(
|
|
settings.policy_file,
|
|
required_scope=settings.required_scope,
|
|
enforce_scope=settings.enforce_scope,
|
|
)
|
|
if settings.qonto_fixture_dir is not None:
|
|
client = FixtureQontoClient(fixture_dir=settings.qonto_fixture_dir)
|
|
else:
|
|
credential_provider = build_credential_provider(settings)
|
|
client = QontoClient(
|
|
base_url=settings.qonto_base_url,
|
|
organization_path=settings.qonto_organization_path,
|
|
transactions_path=settings.qonto_transactions_path,
|
|
auth_mode=settings.qonto_auth_mode,
|
|
timeout_seconds=settings.qonto_timeout_seconds,
|
|
max_retries=settings.qonto_max_retries,
|
|
credential_provider=credential_provider,
|
|
)
|
|
return CapabilityService(
|
|
client=client,
|
|
policy=policy,
|
|
audit_logger=audit_logger,
|
|
rate_limiter=rate_limiter
|
|
or RateLimiter(
|
|
limit=settings.rate_limit_requests,
|
|
window_seconds=settings.rate_limit_window_seconds,
|
|
),
|
|
concurrency_limiter=concurrency_limiter
|
|
or ConcurrencyLimiter(limit=settings.max_concurrency),
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
settings = Settings.from_env()
|
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
uvicorn.run(
|
|
"qonto_assistant.app:create_app",
|
|
factory=True,
|
|
host=settings.host,
|
|
port=settings.port,
|
|
reload=False,
|
|
)
|