QONTO-WP-0003-T03: MCP client auth + shared multi-harness config snippet
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>
This commit is contained in:
parent
e1e47ae304
commit
d2ffd372b5
9 changed files with 259 additions and 5 deletions
|
|
@ -29,10 +29,11 @@ Phase 1 runtime is implemented:
|
||||||
Phase 2 (MCP surface, `workplans/QONTO-WP-0003-mcp-surface.md`) is in
|
Phase 2 (MCP surface, `workplans/QONTO-WP-0003-mcp-surface.md`) is in
|
||||||
progress: a streamable-HTTP MCP adapter is mounted at `/mcp` on the same
|
progress: a streamable-HTTP MCP adapter is mounted at `/mcp` on the same
|
||||||
capability core and policy kernel as REST, with tools `qonto_org_summary`,
|
capability core and policy kernel as REST, with tools `qonto_org_summary`,
|
||||||
`qonto_list_transactions`, and `qonto_cost_run_rate_hints`. Client auth is
|
`qonto_list_transactions`, and `qonto_cost_run_rate_hints`. The endpoint is
|
||||||
still the REST-style `X-Actor-*` header convention; real OIDC/workload auth,
|
gated by a shared-secret bearer token (`QONTO_ASSISTANT_MCP_TOKEN`) — see
|
||||||
the shared multi-harness client config snippet, and the `finance-qonto-read`
|
`docs/mcp-integration.md` for the auth model, its gap vs. the OIDC/workload
|
||||||
tool profile are not yet implemented.
|
target, and the shared multi-harness client config snippet. The
|
||||||
|
`finance-qonto-read` tool profile is not yet implemented.
|
||||||
|
|
||||||
Current verification:
|
Current verification:
|
||||||
|
|
||||||
|
|
|
||||||
119
docs/mcp-integration.md
Normal file
119
docs/mcp-integration.md
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
# MCP Integration (Phase 2)
|
||||||
|
|
||||||
|
`qonto-assistant` exposes a streamable-HTTP MCP adapter at `/mcp`, mounted on
|
||||||
|
the same FastAPI process as the REST surface. It shares the exact policy
|
||||||
|
kernel and audit layer REST uses — see `specs/ArchitectureBlueprint.md` §6.
|
||||||
|
|
||||||
|
Tools exposed (read-only, default-deny policy underneath):
|
||||||
|
|
||||||
|
| MCP tool | Capability | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `qonto_ping` | none (no bank call) | Smoke/liveness check |
|
||||||
|
| `qonto_org_summary` | `org_summary` | Organization + per-account balances |
|
||||||
|
| `qonto_list_transactions` | `list_transactions` | Capped, filtered history |
|
||||||
|
| `qonto_cost_run_rate_hints` | `cost_run_rate_hints` | Normalized recurring-cost hints, not a raw export |
|
||||||
|
|
||||||
|
No spend, transfer, card, or volume-cost tool is ever exposed here — those
|
||||||
|
stay hard-denied by the shared policy kernel regardless of transport.
|
||||||
|
|
||||||
|
## Auth model
|
||||||
|
|
||||||
|
Two independent layers, same as the blueprint's identity separation
|
||||||
|
(`specs/ArchitectureBlueprint.md` §4.7):
|
||||||
|
|
||||||
|
1. **Workload auth (is this caller allowed to reach the service at all?)**
|
||||||
|
Today: a static shared-secret bearer token
|
||||||
|
(`QONTO_ASSISTANT_MCP_TOKEN`), checked by `BearerTokenAuthMiddleware`
|
||||||
|
in front of `/mcp`. If the env var is unset, the middleware is not
|
||||||
|
installed — used only for local fixture-backed smoke work, never for a
|
||||||
|
deployment holding real credentials.
|
||||||
|
|
||||||
|
This is **not** the OIDC/workload-identity target the blueprint
|
||||||
|
describes — there is no OIDC issuer in this fleet yet to federate
|
||||||
|
against. A shared-secret bearer token is the deployable "or similar"
|
||||||
|
primitive for now; upgrading to real OIDC/workload identity is
|
||||||
|
fleet-level follow-on work (tracked as a Phase 2 gap, not closed by this
|
||||||
|
task).
|
||||||
|
|
||||||
|
This token is a **service credential**, not a bank credential — it never
|
||||||
|
reaches Qonto and is never logged. Treat it like any other shared
|
||||||
|
secret: store it in OpenBao or your harness's secret manager, not in
|
||||||
|
plaintext config committed to a repo.
|
||||||
|
|
||||||
|
2. **Actor identity (who is calling, for policy and audit purposes?)**
|
||||||
|
Same `X-Actor-ID` / `X-Tenant-ID` / `X-Actor-Lane` / `X-Actor-Scopes`
|
||||||
|
header convention REST already uses (`auth.actor_claims_from_headers`).
|
||||||
|
These are self-asserted today, not cryptographically bound to the bearer
|
||||||
|
token — tightening that binding is exactly what Phase 3's flex-auth
|
||||||
|
resource (`finance.qonto.read`) is for.
|
||||||
|
|
||||||
|
## One shared client config snippet
|
||||||
|
|
||||||
|
The same `url` + `headers` shape works across MCP-capable harnesses that
|
||||||
|
support a remote streamable-HTTP server (Claude Code, Claude Desktop,
|
||||||
|
Cursor, and Codex/Grok-style harnesses that follow the MCP client config
|
||||||
|
convention):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"qonto-assistant": {
|
||||||
|
"url": "http://127.0.0.1:8080/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${QONTO_ASSISTANT_MCP_TOKEN}",
|
||||||
|
"X-Actor-ID": "${AGENT_ACTOR_ID}",
|
||||||
|
"X-Tenant-ID": "binky"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the `url` host/port with wherever the service is actually deployed.
|
||||||
|
`${QONTO_ASSISTANT_MCP_TOKEN}` and `${AGENT_ACTOR_ID}` are placeholders for
|
||||||
|
whatever secret-interpolation syntax your harness config supports — never
|
||||||
|
paste the literal token into a shared config file.
|
||||||
|
|
||||||
|
## Local smoke: start with auth enabled, connect with only this snippet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export QONTO_FIXTURE_DIR=tests/fixtures/qonto
|
||||||
|
export QONTO_ASSISTANT_MCP_TOKEN=dev-local-token
|
||||||
|
make run # or: PYTHONPATH=src ../state-hub/.venv/bin/python -m qonto_assistant.main
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, using only the URL and `Authorization: Bearer dev-local-token` header
|
||||||
|
from the snippet above, connect with the `mcp` Python SDK's client (or any
|
||||||
|
MCP Inspector-style tool):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from mcp import ClientSession
|
||||||
|
from mcp.client.streamable_http import streamablehttp_client
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with streamablehttp_client(
|
||||||
|
"http://127.0.0.1:8080/mcp",
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer dev-local-token",
|
||||||
|
"X-Actor-ID": "local-operator",
|
||||||
|
"X-Tenant-ID": "binky",
|
||||||
|
},
|
||||||
|
) as (read, write, _):
|
||||||
|
async with ClientSession(read, write) as session:
|
||||||
|
await session.initialize()
|
||||||
|
print([tool.name for tool in (await session.list_tools()).tools])
|
||||||
|
print((await session.call_tool("qonto_org_summary", {})).structuredContent)
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
A request without the `Authorization` header (or with the wrong token) gets
|
||||||
|
a `401 {"error_code": "unauthorized"}` before it ever reaches a tool.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `docs/operator-runbook.md` — REST operator runbook (credential sources,
|
||||||
|
fixture mode, `/v1/*` endpoints)
|
||||||
|
- `specs/ArchitectureBlueprint.md` §6 Phase 2 — the plan this doc implements
|
||||||
|
- `workplans/QONTO-WP-0003-mcp-surface.md` — Phase 2 workplan and task status
|
||||||
|
|
@ -162,6 +162,13 @@ PY
|
||||||
Use `window_days=31` for a recent-activity view. Use `window_days=90` or `93`
|
Use `window_days=31` for a recent-activity view. Use `window_days=90` or `93`
|
||||||
when recurring fixed-cost hints are required.
|
when recurring fixed-cost hints are required.
|
||||||
|
|
||||||
|
## MCP surface
|
||||||
|
|
||||||
|
Phase 2 mounts a streamable-HTTP MCP adapter at `/mcp` on this same process,
|
||||||
|
sharing the policy kernel and audit layer above. See
|
||||||
|
`docs/mcp-integration.md` for the tool catalog, the auth model
|
||||||
|
(`QONTO_ASSISTANT_MCP_TOKEN`), and the shared client config snippet.
|
||||||
|
|
||||||
## CostRunRate refresh path
|
## CostRunRate refresh path
|
||||||
|
|
||||||
`binky-control` should consume `GET /v1/snapshot` and extract:
|
`binky-control` should consume `GET /v1/snapshot` and extract:
|
||||||
|
|
|
||||||
|
|
@ -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_auth import BearerTokenAuthMiddleware
|
||||||
from qonto_assistant.mcp_server import create_mcp_server
|
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
|
||||||
|
|
@ -42,6 +43,8 @@ def create_app(
|
||||||
|
|
||||||
mcp_server = create_mcp_server(settings=settings, service=service)
|
mcp_server = create_mcp_server(settings=settings, service=service)
|
||||||
mcp_app = mcp_server.streamable_http_app()
|
mcp_app = mcp_server.streamable_http_app()
|
||||||
|
if settings.mcp_auth_token:
|
||||||
|
mcp_app.add_middleware(BearerTokenAuthMiddleware, token=settings.mcp_auth_token)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ class Settings:
|
||||||
openbao_path: str
|
openbao_path: str
|
||||||
openbao_command: str
|
openbao_command: str
|
||||||
openbao_timeout_seconds: float
|
openbao_timeout_seconds: float
|
||||||
|
mcp_auth_token: str | None
|
||||||
host: str
|
host: str
|
||||||
port: int
|
port: int
|
||||||
|
|
||||||
|
|
@ -69,6 +70,7 @@ class Settings:
|
||||||
openbao_path=os.getenv("QONTO_OPENBAO_PATH", "tenants/binky/qonto-api"),
|
openbao_path=os.getenv("QONTO_OPENBAO_PATH", "tenants/binky/qonto-api"),
|
||||||
openbao_command=os.getenv("QONTO_OPENBAO_COMMAND", "bao"),
|
openbao_command=os.getenv("QONTO_OPENBAO_COMMAND", "bao"),
|
||||||
openbao_timeout_seconds=float(os.getenv("QONTO_OPENBAO_TIMEOUT_SECONDS", "5")),
|
openbao_timeout_seconds=float(os.getenv("QONTO_OPENBAO_TIMEOUT_SECONDS", "5")),
|
||||||
|
mcp_auth_token=os.getenv("QONTO_ASSISTANT_MCP_TOKEN") or None,
|
||||||
host=os.getenv("QONTO_ASSISTANT_HOST", "127.0.0.1"),
|
host=os.getenv("QONTO_ASSISTANT_HOST", "127.0.0.1"),
|
||||||
port=int(os.getenv("QONTO_ASSISTANT_PORT", "8080")),
|
port=int(os.getenv("QONTO_ASSISTANT_PORT", "8080")),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
52
src/qonto_assistant/mcp_auth.py
Normal file
52
src/qonto_assistant/mcp_auth.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
from starlette.requests import Headers
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||||
|
|
||||||
|
|
||||||
|
class BearerTokenAuthMiddleware:
|
||||||
|
"""Workload auth gate for the MCP endpoint: static shared-secret bearer token.
|
||||||
|
|
||||||
|
QONTO-WP-0003-T03 scope note: the blueprint's target is OIDC/workload
|
||||||
|
identity (specs/ArchitectureBlueprint.md §4.7), but no OIDC issuer exists
|
||||||
|
in this fleet yet. A shared-secret bearer token is the "or similar"
|
||||||
|
workload-auth primitive that's actually deployable today -- it keeps
|
||||||
|
unauthenticated callers off the MCP surface entirely, same custody
|
||||||
|
principle as REST: this token is a service credential, never a bank
|
||||||
|
credential, and per-actor identity still comes from X-Actor-* headers
|
||||||
|
checked by the policy kernel. Upgrading to real OIDC/workload identity
|
||||||
|
federation is fleet-level follow-on work, not something this repo can
|
||||||
|
stand up alone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp, *, token: str) -> None:
|
||||||
|
self.app = app
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
headers = Headers(scope=scope)
|
||||||
|
if not _token_matches(headers.get("authorization", ""), self.token):
|
||||||
|
response = JSONResponse(
|
||||||
|
{"error_code": "unauthorized", "detail": "Missing or invalid bearer token"},
|
||||||
|
status_code=401,
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
await response(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
def _token_matches(authorization_header: str, expected_token: str) -> bool:
|
||||||
|
prefix = "Bearer "
|
||||||
|
if not authorization_header.startswith(prefix):
|
||||||
|
return False
|
||||||
|
presented = authorization_header[len(prefix) :]
|
||||||
|
return hmac.compare_digest(presented, expected_token)
|
||||||
|
|
@ -38,6 +38,7 @@ def _settings() -> Settings:
|
||||||
openbao_path="tenants/binky/qonto-api",
|
openbao_path="tenants/binky/qonto-api",
|
||||||
openbao_command="bao",
|
openbao_command="bao",
|
||||||
openbao_timeout_seconds=5,
|
openbao_timeout_seconds=5,
|
||||||
|
mcp_auth_token=None,
|
||||||
host="127.0.0.1",
|
host="127.0.0.1",
|
||||||
port=8080,
|
port=8080,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
35
tests/test_mcp_auth.py
Normal file
35
tests/test_mcp_auth.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.responses import PlainTextResponse
|
||||||
|
from starlette.routing import Route
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from qonto_assistant.mcp_auth import BearerTokenAuthMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def _protected_app() -> Starlette:
|
||||||
|
async def ok(request):
|
||||||
|
return PlainTextResponse("ok")
|
||||||
|
|
||||||
|
app = Starlette(routes=[Route("/", ok)])
|
||||||
|
app.add_middleware(BearerTokenAuthMiddleware, token="secret-token")
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_bearer_auth_rejects_missing_header() -> None:
|
||||||
|
client = TestClient(_protected_app())
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json()["error_code"] == "unauthorized"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bearer_auth_rejects_wrong_token() -> None:
|
||||||
|
client = TestClient(_protected_app())
|
||||||
|
response = client.get("/", headers={"Authorization": "Bearer wrong-token"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_bearer_auth_accepts_matching_token() -> None:
|
||||||
|
client = TestClient(_protected_app())
|
||||||
|
response = client.get("/", headers={"Authorization": "Bearer secret-token"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == "ok"
|
||||||
|
|
@ -122,7 +122,7 @@ REST. `pytest` → `25 passed`; `python3 -m compileall src tests scripts`.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: QONTO-WP-0003-T03
|
id: QONTO-WP-0003-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "ad43b09a-ffa5-4df0-8999-bfcef1f11732"
|
state_hub_task_id: "ad43b09a-ffa5-4df0-8999-bfcef1f11732"
|
||||||
```
|
```
|
||||||
|
|
@ -137,6 +137,40 @@ Done when: the snippet is in `docs/operator-runbook.md` (or a new
|
||||||
`docs/mcp-integration.md`), and a local smoke connects a real MCP client
|
`docs/mcp-integration.md`), and a local smoke connects a real MCP client
|
||||||
against the running adapter using only that snippet.
|
against the running adapter using only that snippet.
|
||||||
|
|
||||||
|
**Done 2026-07-23 — with a scoped gap, called out explicitly:** No OIDC
|
||||||
|
issuer exists anywhere in this fleet yet, so standing up real
|
||||||
|
OIDC/workload-identity federation isn't something this repo can do alone —
|
||||||
|
implementing it would mean pointing `FastMCP`'s OAuth Protected Resource
|
||||||
|
flow at an issuer URL that doesn't serve real metadata, which is worse than
|
||||||
|
not having it. Shipped the deployable primitive instead: a shared-secret
|
||||||
|
bearer token (`QONTO_ASSISTANT_MCP_TOKEN`), enforced by
|
||||||
|
`src/qonto_assistant/mcp_auth.py::BearerTokenAuthMiddleware` in front of
|
||||||
|
`/mcp` only (REST untouched). Unset by default (fixture/local smoke stays
|
||||||
|
credential-free); when set, every non-matching or missing
|
||||||
|
`Authorization: Bearer <token>` gets `401 {"error_code": "unauthorized"}`
|
||||||
|
before reaching any tool or the policy kernel. Constant-time comparison
|
||||||
|
(`hmac.compare_digest`). This token is a **service credential, not a bank
|
||||||
|
credential** — never logged, never reaches Qonto. Per-actor identity is
|
||||||
|
still the self-asserted `X-Actor-*` header convention (not yet
|
||||||
|
cryptographically bound to the bearer token — that binding is Phase 3's
|
||||||
|
flex-auth resource `finance.qonto.read`, not this task).
|
||||||
|
|
||||||
|
Added `docs/mcp-integration.md`: tool table, the two-layer auth model
|
||||||
|
explained (workload auth today vs. the OIDC target and why it's deferred),
|
||||||
|
and the one shared `{"mcpServers": {...}}` config snippet (`url` + `headers`
|
||||||
|
— the convention shared by Claude Code, Claude Desktop, Cursor, and
|
||||||
|
Codex/Grok-style harnesses for remote streamable-HTTP MCP servers).
|
||||||
|
|
||||||
|
Verified live, exactly as the doc's smoke section describes: started the
|
||||||
|
service with `QONTO_ASSISTANT_MCP_TOKEN` set and `QONTO_FIXTURE_DIR` (no
|
||||||
|
real Qonto credentials); a request with no `Authorization` header never
|
||||||
|
reached a tool; a request with the wrong token got `401 Unauthorized` in the
|
||||||
|
server log; a request built from *only* the doc's snippet (URL + the two
|
||||||
|
headers) listed tools and called `qonto_org_summary` successfully, with
|
||||||
|
`X-Actor-ID` flowing into the audit event as before. New
|
||||||
|
`tests/test_mcp_auth.py` covers missing/wrong/matching token cases directly.
|
||||||
|
`pytest` → `28 passed`; `python3 -m compileall src tests scripts`.
|
||||||
|
|
||||||
## Task: agent-harness tool profile `finance-qonto-read`
|
## Task: agent-harness tool profile `finance-qonto-read`
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue