QONTO-WP-0003-T02: MCP tool catalog on the shared capability core
Add qonto_org_summary, qonto_list_transactions, and qonto_cost_run_rate_hints MCP tools, all routed through CapabilityService with protocol="mcp" -- same PolicyEngine.decide() path as REST, same deny-reason vocabulary. Skip snapshot_bundle as an MCP tool (REST already covers the composite read; not a separate privilege). CapabilityService now threads protocol through _execute/_emit_audit instead of hardcoding "rest". cost_run_rate_hints gets its own service method since it's an independent policy capability, not only a snapshot sub-field. Actor identity reuses REST's X-Actor-* header convention via a shared auth.actor_claims_from_headers(), read from the MCP Context's request when present. Fixed streamable_http_path defaulting to "/mcp", which doubled to "/mcp/mcp" once mounted under the "/mcp" prefix. Verified end-to-end with the mcp SDK's streamablehttp_client against the live fixture-backed server: tool list, allow/deny paths, and X-Actor-ID flowing through to the audit log exactly like REST. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
ba2612682f
commit
b4b1dc1c7b
7 changed files with 284 additions and 29 deletions
|
|
@ -26,6 +26,14 @@ Phase 1 runtime is implemented:
|
||||||
- REST endpoints: `/v1/health`, `/v1/accounts`, `/v1/transactions`, `/v1/snapshot`
|
- REST endpoints: `/v1/health`, `/v1/accounts`, `/v1/transactions`, `/v1/snapshot`
|
||||||
- audit metadata, rate limiting, concurrency bounds, tests
|
- audit metadata, rate limiting, concurrency bounds, tests
|
||||||
|
|
||||||
|
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
|
||||||
|
capability core and policy kernel as REST, with tools `qonto_org_summary`,
|
||||||
|
`qonto_list_transactions`, and `qonto_cost_run_rate_hints`. Client auth is
|
||||||
|
still the REST-style `X-Actor-*` header convention; real OIDC/workload auth,
|
||||||
|
the shared multi-harness client config snippet, and the `finance-qonto-read`
|
||||||
|
tool profile are not yet implemented.
|
||||||
|
|
||||||
Current verification:
|
Current verification:
|
||||||
|
|
||||||
- `PYTHONPATH=src ../state-hub/.venv/bin/python -m pytest` → `16 passed`
|
- `PYTHONPATH=src ../state-hub/.venv/bin/python -m pytest` → `16 passed`
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ def create_app(
|
||||||
concurrency_limiter=concurrency_limiter,
|
concurrency_limiter=concurrency_limiter,
|
||||||
)
|
)
|
||||||
|
|
||||||
mcp_server = create_mcp_server(settings=settings)
|
mcp_server = create_mcp_server(settings=settings, service=service)
|
||||||
mcp_app = mcp_server.streamable_http_app()
|
mcp_app = mcp_server.streamable_http_app()
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,26 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
from qonto_assistant.config import Settings
|
from qonto_assistant.config import Settings
|
||||||
from qonto_assistant.contracts import ActorClaims
|
from qonto_assistant.contracts import ActorClaims
|
||||||
|
|
||||||
|
|
||||||
def actor_claims_from_request(request: Request, settings: Settings) -> ActorClaims:
|
def actor_claims_from_headers(headers: Mapping[str, str], settings: Settings) -> ActorClaims:
|
||||||
actor_id = request.headers.get("x-actor-id", "anonymous")
|
"""Shared REST/MCP claims parsing so both transports enforce identical actor identity.
|
||||||
tenant_id = request.headers.get("x-tenant-id", settings.default_tenant_id)
|
|
||||||
lane = request.headers.get("x-actor-lane", settings.default_actor_lane)
|
Real workload/OIDC auth for the MCP transport is QONTO-WP-0003-T03; until
|
||||||
raw_scopes = request.headers.get("x-actor-scopes", "")
|
then MCP callers use the same X-Actor-* header convention as REST.
|
||||||
|
"""
|
||||||
|
actor_id = headers.get("x-actor-id", "anonymous")
|
||||||
|
tenant_id = headers.get("x-tenant-id", settings.default_tenant_id)
|
||||||
|
lane = headers.get("x-actor-lane", settings.default_actor_lane)
|
||||||
|
raw_scopes = headers.get("x-actor-scopes", "")
|
||||||
scopes = frozenset(scope.strip() for scope in raw_scopes.split(",") if scope.strip())
|
scopes = frozenset(scope.strip() for scope in raw_scopes.split(",") if scope.strip())
|
||||||
return ActorClaims(actor_id=actor_id, tenant_id=tenant_id, lane=lane, scopes=scopes)
|
return ActorClaims(actor_id=actor_id, tenant_id=tenant_id, lane=lane, scopes=scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def actor_claims_from_request(request: Request, settings: Settings) -> ActorClaims:
|
||||||
|
return actor_claims_from_headers(request.headers, settings)
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,26 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
|
|
||||||
from qonto_assistant import __version__
|
from qonto_assistant import __version__
|
||||||
|
from qonto_assistant.auth import actor_claims_from_headers
|
||||||
from qonto_assistant.config import Settings
|
from qonto_assistant.config import Settings
|
||||||
|
from qonto_assistant.service import CapabilityService
|
||||||
|
|
||||||
|
|
||||||
def create_mcp_server(*, settings: Settings) -> FastMCP:
|
def create_mcp_server(*, settings: Settings, service: CapabilityService | None = None) -> FastMCP:
|
||||||
"""Build the MCP adapter on the same capability core as the REST surface.
|
"""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
|
Every tool here maps 1:1 to a REST-allowed capability id and goes through
|
||||||
tool. Real read tools (`qonto_org_summary`, `qonto_list_transactions`,
|
`CapabilityService`, which in turn calls the shared
|
||||||
`qonto_cost_run_rate_hints`) land in QONTO-WP-0003-T02, routed through the
|
`PolicyEngine.decide()` -- no separate MCP policy path. Client auth is
|
||||||
same `CapabilityRequest` -> `PolicyEngine.decide()` path REST already
|
still the simple X-Actor-* header convention (QONTO-WP-0003-T03 upgrades
|
||||||
uses -- no policy fork between transports.
|
this to real OIDC/workload auth); no bank secrets are ever exposed here.
|
||||||
"""
|
"""
|
||||||
server = FastMCP(
|
server = FastMCP(
|
||||||
name=settings.service_name,
|
name=settings.service_name,
|
||||||
|
|
@ -23,6 +29,10 @@ def create_mcp_server(*, settings: Settings) -> FastMCP:
|
||||||
"No spend, transfer, card, or volume-cost tools are exposed here."
|
"No spend, transfer, card, or volume-cost tools are exposed here."
|
||||||
),
|
),
|
||||||
stateless_http=True,
|
stateless_http=True,
|
||||||
|
# Mounted at "/mcp" by the parent FastAPI app (see app.py); keep this
|
||||||
|
# sub-app's own route at root so the final path is "/mcp", not
|
||||||
|
# "/mcp/mcp".
|
||||||
|
streamable_http_path="/",
|
||||||
)
|
)
|
||||||
|
|
||||||
@server.tool()
|
@server.tool()
|
||||||
|
|
@ -30,8 +40,80 @@ def create_mcp_server(*, settings: Settings) -> FastMCP:
|
||||||
"""Smoke tool confirming the MCP adapter is reachable. No bank call."""
|
"""Smoke tool confirming the MCP adapter is reachable. No bank call."""
|
||||||
return {"status": "ok", "service": settings.service_name, "version": __version__}
|
return {"status": "ok", "service": settings.service_name, "version": __version__}
|
||||||
|
|
||||||
|
if service is not None:
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def qonto_org_summary(ctx: Context) -> dict[str, Any]:
|
||||||
|
"""Organization name and per-account balances (org_summary capability)."""
|
||||||
|
claims = _claims(ctx, settings)
|
||||||
|
return service.get_accounts(claims=claims, request_id=_request_id(ctx), protocol="mcp")
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def qonto_list_transactions(
|
||||||
|
ctx: Context,
|
||||||
|
account_slug: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 50,
|
||||||
|
window_days: int = 31,
|
||||||
|
status: str | None = "completed",
|
||||||
|
side: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Capped, filtered transaction history (list_transactions capability)."""
|
||||||
|
claims = _claims(ctx, settings)
|
||||||
|
return service.list_transactions(
|
||||||
|
claims=claims,
|
||||||
|
request_id=_request_id(ctx),
|
||||||
|
account_slug=account_slug,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
window_days=window_days,
|
||||||
|
status=status,
|
||||||
|
side=side,
|
||||||
|
protocol="mcp",
|
||||||
|
)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def qonto_cost_run_rate_hints(
|
||||||
|
ctx: Context,
|
||||||
|
window_days: int = 90,
|
||||||
|
page_size: int = 50,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Normalized recurring-cost hints, not a raw export (cost_run_rate_hints capability)."""
|
||||||
|
claims = _claims(ctx, settings)
|
||||||
|
return service.get_cost_run_rate_hints(
|
||||||
|
claims=claims,
|
||||||
|
request_id=_request_id(ctx),
|
||||||
|
window_days=window_days,
|
||||||
|
page_size=page_size,
|
||||||
|
protocol="mcp",
|
||||||
|
)
|
||||||
|
|
||||||
return server
|
return server
|
||||||
|
|
||||||
|
|
||||||
def mcp_asgi_app(*, settings: Settings) -> Starlette:
|
def mcp_asgi_app(*, settings: Settings, service: CapabilityService | None = None) -> Starlette:
|
||||||
return create_mcp_server(settings=settings).streamable_http_app()
|
return create_mcp_server(settings=settings, service=service).streamable_http_app()
|
||||||
|
|
||||||
|
|
||||||
|
def _claims(ctx: Context, settings: Settings):
|
||||||
|
headers = _headers_from_context(ctx)
|
||||||
|
return actor_claims_from_headers(headers, settings)
|
||||||
|
|
||||||
|
|
||||||
|
def _headers_from_context(ctx: Context) -> Mapping[str, str]:
|
||||||
|
# request_context is unset outside a live MCP request (e.g. stdio
|
||||||
|
# transport, or a tool invoked directly in tests) -- fall back to the
|
||||||
|
# same default-actor behavior REST uses for header-less callers.
|
||||||
|
try:
|
||||||
|
request_context = ctx.request_context
|
||||||
|
except ValueError:
|
||||||
|
return {}
|
||||||
|
request = getattr(request_context, "request", None)
|
||||||
|
headers = getattr(request, "headers", None)
|
||||||
|
return headers if headers is not None else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _request_id(ctx: Context) -> str:
|
||||||
|
headers = _headers_from_context(ctx)
|
||||||
|
header_id = headers.get("x-request-id")
|
||||||
|
return header_id if header_id else str(uuid4())
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from qonto_assistant.audit import AuditLogger, utc_now_iso
|
from qonto_assistant.audit import AuditLogger, utc_now_iso
|
||||||
from qonto_assistant.contracts import ActorClaims, AuditEvent, CapabilityRequest
|
from qonto_assistant.contracts import ActorClaims, AuditEvent, CapabilityRequest, ProtocolName
|
||||||
from qonto_assistant.errors import InvalidRequestError, PolicyDeniedError, UpstreamError
|
from qonto_assistant.errors import InvalidRequestError, PolicyDeniedError, UpstreamError
|
||||||
from qonto_assistant.policy import PolicyEngine
|
from qonto_assistant.policy import PolicyEngine
|
||||||
from qonto_assistant.qonto_client import QontoClientProtocol
|
from qonto_assistant.qonto_client import QontoClientProtocol
|
||||||
|
|
@ -30,13 +30,16 @@ class CapabilityService:
|
||||||
self.rate_limiter = rate_limiter
|
self.rate_limiter = rate_limiter
|
||||||
self.concurrency_limiter = concurrency_limiter
|
self.concurrency_limiter = concurrency_limiter
|
||||||
|
|
||||||
def get_accounts(self, *, claims: ActorClaims, request_id: str) -> dict[str, Any]:
|
def get_accounts(
|
||||||
|
self, *, claims: ActorClaims, request_id: str, protocol: ProtocolName = "rest"
|
||||||
|
) -> dict[str, Any]:
|
||||||
return self._execute(
|
return self._execute(
|
||||||
capability_id="org_summary",
|
capability_id="org_summary",
|
||||||
claims=claims,
|
claims=claims,
|
||||||
request_args={},
|
request_args={},
|
||||||
resource_scope="accounts",
|
resource_scope="accounts",
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
|
protocol=protocol,
|
||||||
operation=self._build_accounts_payload,
|
operation=self._build_accounts_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -51,6 +54,7 @@ class CapabilityService:
|
||||||
window_days: int,
|
window_days: int,
|
||||||
status: str | None,
|
status: str | None,
|
||||||
side: str | None,
|
side: str | None,
|
||||||
|
protocol: ProtocolName = "rest",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return self._execute(
|
return self._execute(
|
||||||
capability_id="list_transactions",
|
capability_id="list_transactions",
|
||||||
|
|
@ -65,6 +69,7 @@ class CapabilityService:
|
||||||
},
|
},
|
||||||
resource_scope="transactions",
|
resource_scope="transactions",
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
|
protocol=protocol,
|
||||||
operation=lambda _: self._build_transactions_payload(
|
operation=lambda _: self._build_transactions_payload(
|
||||||
account_slug=account_slug,
|
account_slug=account_slug,
|
||||||
page=page,
|
page=page,
|
||||||
|
|
@ -82,6 +87,7 @@ class CapabilityService:
|
||||||
request_id: str,
|
request_id: str,
|
||||||
window_days: int,
|
window_days: int,
|
||||||
page_size: int,
|
page_size: int,
|
||||||
|
protocol: ProtocolName = "rest",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return self._execute(
|
return self._execute(
|
||||||
capability_id="snapshot_bundle",
|
capability_id="snapshot_bundle",
|
||||||
|
|
@ -89,9 +95,31 @@ class CapabilityService:
|
||||||
request_args={"window_days": window_days, "page_size": page_size},
|
request_args={"window_days": window_days, "page_size": page_size},
|
||||||
resource_scope="snapshot",
|
resource_scope="snapshot",
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
|
protocol=protocol,
|
||||||
operation=lambda _: self._build_snapshot_payload(window_days=window_days, page_size=page_size),
|
operation=lambda _: self._build_snapshot_payload(window_days=window_days, page_size=page_size),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_cost_run_rate_hints(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
claims: ActorClaims,
|
||||||
|
request_id: str,
|
||||||
|
window_days: int,
|
||||||
|
page_size: int,
|
||||||
|
protocol: ProtocolName = "rest",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return self._execute(
|
||||||
|
capability_id="cost_run_rate_hints",
|
||||||
|
claims=claims,
|
||||||
|
request_args={"window_days": window_days, "page_size": page_size},
|
||||||
|
resource_scope="cost_run_rate_hints",
|
||||||
|
request_id=request_id,
|
||||||
|
protocol=protocol,
|
||||||
|
operation=lambda _: self._build_cost_run_rate_hints_payload(
|
||||||
|
window_days=window_days, page_size=page_size
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def _execute(
|
def _execute(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -101,6 +129,7 @@ class CapabilityService:
|
||||||
resource_scope: str,
|
resource_scope: str,
|
||||||
request_id: str,
|
request_id: str,
|
||||||
operation,
|
operation,
|
||||||
|
protocol: ProtocolName = "rest",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
request = CapabilityRequest(
|
request = CapabilityRequest(
|
||||||
capability_id=capability_id,
|
capability_id=capability_id,
|
||||||
|
|
@ -108,7 +137,7 @@ class CapabilityService:
|
||||||
actor_claims=claims,
|
actor_claims=claims,
|
||||||
resource_scope=resource_scope,
|
resource_scope=resource_scope,
|
||||||
request_args=request_args,
|
request_args=request_args,
|
||||||
protocol="rest",
|
protocol=protocol,
|
||||||
)
|
)
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
decision = self.policy.decide(request)
|
decision = self.policy.decide(request)
|
||||||
|
|
@ -122,6 +151,7 @@ class CapabilityService:
|
||||||
latency_ms=_latency_ms(started),
|
latency_ms=_latency_ms(started),
|
||||||
result_count=None,
|
result_count=None,
|
||||||
qonto_http_status=None,
|
qonto_http_status=None,
|
||||||
|
protocol=protocol,
|
||||||
)
|
)
|
||||||
raise PolicyDeniedError(decision)
|
raise PolicyDeniedError(decision)
|
||||||
|
|
||||||
|
|
@ -140,6 +170,7 @@ class CapabilityService:
|
||||||
latency_ms=_latency_ms(started),
|
latency_ms=_latency_ms(started),
|
||||||
result_count=_result_count(payload),
|
result_count=_result_count(payload),
|
||||||
qonto_http_status=200,
|
qonto_http_status=200,
|
||||||
|
protocol=protocol,
|
||||||
)
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
@ -216,6 +247,28 @@ class CapabilityService:
|
||||||
"recent_transactions": recent_transactions[:10],
|
"recent_transactions": recent_transactions[:10],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _build_cost_run_rate_hints_payload(self, *, window_days: int, page_size: int) -> dict[str, Any]:
|
||||||
|
accounts_payload = self._build_accounts_payload({})
|
||||||
|
accounts = accounts_payload["accounts"]
|
||||||
|
main_account = next((account for account in accounts if account["main"]), accounts[0] if accounts else None)
|
||||||
|
recent_transactions: list[dict[str, Any]] = []
|
||||||
|
if main_account is not None:
|
||||||
|
transactions_payload = self._build_transactions_payload(
|
||||||
|
account_slug=main_account["slug"],
|
||||||
|
page=1,
|
||||||
|
page_size=min(page_size, 50),
|
||||||
|
window_days=window_days,
|
||||||
|
status="completed",
|
||||||
|
side=None,
|
||||||
|
)
|
||||||
|
recent_transactions = transactions_payload["transactions"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"organization": accounts_payload["organization"],
|
||||||
|
"window_days": window_days,
|
||||||
|
"cost_run_rate_hints": _build_cost_run_rate_hints(recent_transactions),
|
||||||
|
}
|
||||||
|
|
||||||
def _emit_audit(
|
def _emit_audit(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -227,6 +280,7 @@ class CapabilityService:
|
||||||
latency_ms: int,
|
latency_ms: int,
|
||||||
result_count: int | None,
|
result_count: int | None,
|
||||||
qonto_http_status: int | None,
|
qonto_http_status: int | None,
|
||||||
|
protocol: ProtocolName = "rest",
|
||||||
) -> None:
|
) -> None:
|
||||||
event = AuditEvent(
|
event = AuditEvent(
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
|
|
@ -234,7 +288,7 @@ class CapabilityService:
|
||||||
actor=claims.actor_id,
|
actor=claims.actor_id,
|
||||||
tenant_id=claims.tenant_id,
|
tenant_id=claims.tenant_id,
|
||||||
capability=capability_id,
|
capability=capability_id,
|
||||||
protocol="rest",
|
protocol=protocol,
|
||||||
decision=decision,
|
decision=decision,
|
||||||
deny_reason=deny_reason,
|
deny_reason=deny_reason,
|
||||||
policy_version=self.policy.version,
|
policy_version=self.policy.version,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,37 @@
|
||||||
import pytest
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from mcp.server.fastmcp.exceptions import ToolError
|
||||||
|
|
||||||
|
from qonto_assistant.audit import AuditLogger
|
||||||
from qonto_assistant.config import Settings
|
from qonto_assistant.config import Settings
|
||||||
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.qonto_client import FixtureQontoClient
|
||||||
|
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
||||||
|
from qonto_assistant.service import CapabilityService
|
||||||
|
|
||||||
|
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
|
||||||
|
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||||
|
|
||||||
|
|
||||||
def _settings() -> Settings:
|
def _settings() -> Settings:
|
||||||
settings = Settings.from_env()
|
return Settings.from_env()
|
||||||
return settings
|
|
||||||
|
|
||||||
|
def _service() -> tuple[CapabilityService, list[dict[str, object]]]:
|
||||||
|
events: list[dict[str, object]] = []
|
||||||
|
policy = PolicyEngine.from_file(POLICY_FILE, required_scope="finance.qonto.read", enforce_scope=False)
|
||||||
|
service = CapabilityService(
|
||||||
|
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
|
||||||
|
policy=policy,
|
||||||
|
audit_logger=AuditLogger(sink=events.append),
|
||||||
|
rate_limiter=RateLimiter(limit=20, window_seconds=60),
|
||||||
|
concurrency_limiter=ConcurrencyLimiter(limit=4),
|
||||||
|
)
|
||||||
|
return service, events
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mcp_server_starts_and_lists_smoke_tool() -> None:
|
async def test_mcp_server_starts_and_lists_smoke_tool() -> None:
|
||||||
server = create_mcp_server(settings=_settings())
|
server = create_mcp_server(settings=_settings())
|
||||||
tools = await server.list_tools()
|
tools = await server.list_tools()
|
||||||
|
|
@ -17,9 +39,63 @@ async def test_mcp_server_starts_and_lists_smoke_tool() -> None:
|
||||||
assert "qonto_ping" in tool_names
|
assert "qonto_ping" in tool_names
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
async def test_mcp_server_without_service_only_exposes_smoke_tool() -> None:
|
||||||
async def test_mcp_server_smoke_tool_returns_no_bank_call() -> None:
|
|
||||||
server = create_mcp_server(settings=_settings())
|
server = create_mcp_server(settings=_settings())
|
||||||
result = await server.call_tool("qonto_ping", {})
|
tools = await server.list_tools()
|
||||||
payload = result[1] if isinstance(result, tuple) else result
|
tool_names = {tool.name for tool in tools}
|
||||||
assert payload is not None
|
assert tool_names == {"qonto_ping"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mcp_server_lists_capability_tools_when_service_wired() -> None:
|
||||||
|
service, _ = _service()
|
||||||
|
server = create_mcp_server(settings=_settings(), service=service)
|
||||||
|
tools = await server.list_tools()
|
||||||
|
tool_names = {tool.name for tool in tools}
|
||||||
|
assert tool_names == {
|
||||||
|
"qonto_ping",
|
||||||
|
"qonto_org_summary",
|
||||||
|
"qonto_list_transactions",
|
||||||
|
"qonto_cost_run_rate_hints",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_qonto_org_summary_tool_returns_redacted_balances() -> None:
|
||||||
|
service, events = _service()
|
||||||
|
server = create_mcp_server(settings=_settings(), service=service)
|
||||||
|
|
||||||
|
result = await server.call_tool("qonto_org_summary", {})
|
||||||
|
|
||||||
|
assert result[1]["organization"]["name"] == "Binky Hedgehog GmbH"
|
||||||
|
assert "iban" not in result[1]["accounts"][0]
|
||||||
|
assert events[-1]["capability"] == "org_summary"
|
||||||
|
assert events[-1]["protocol"] == "mcp"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_qonto_list_transactions_tool_denies_oversized_page_size() -> None:
|
||||||
|
service, events = _service()
|
||||||
|
server = create_mcp_server(settings=_settings(), service=service)
|
||||||
|
|
||||||
|
with pytest.raises(ToolError):
|
||||||
|
await server.call_tool("qonto_list_transactions", {"page_size": 101})
|
||||||
|
|
||||||
|
assert events[-1]["decision"] == "deny"
|
||||||
|
assert events[-1]["deny_reason"] == "arg_constraint"
|
||||||
|
assert events[-1]["protocol"] == "mcp"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_qonto_cost_run_rate_hints_tool_returns_recurring_debits() -> None:
|
||||||
|
service, events = _service()
|
||||||
|
server = create_mcp_server(settings=_settings(), service=service)
|
||||||
|
|
||||||
|
result = await server.call_tool("qonto_cost_run_rate_hints", {"window_days": 90})
|
||||||
|
|
||||||
|
hints = result[1]["cost_run_rate_hints"]["recurring_debits"]
|
||||||
|
assert len(hints) > 0
|
||||||
|
assert all("amount" in hint for hint in hints)
|
||||||
|
assert events[-1]["capability"] == "cost_run_rate_hints"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_capability_tools_are_absent_without_service() -> None:
|
||||||
|
server = create_mcp_server(settings=_settings())
|
||||||
|
with pytest.raises(ToolError):
|
||||||
|
await server.call_tool("qonto_org_summary", {})
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ QONTO-WP-0003-T02.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: QONTO-WP-0003-T02
|
id: QONTO-WP-0003-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "f9e0d403-d6ae-4800-8d42-847c3d70827f"
|
state_hub_task_id: "f9e0d403-d6ae-4800-8d42-847c3d70827f"
|
||||||
```
|
```
|
||||||
|
|
@ -94,6 +94,30 @@ Done when: unit tests cover allow paths for each tool and deny paths for
|
||||||
out-of-catalog / spend-shaped tool names, mirroring the REST policy tests from
|
out-of-catalog / spend-shaped tool names, mirroring the REST policy tests from
|
||||||
QONTO-WP-0002-T02.
|
QONTO-WP-0002-T02.
|
||||||
|
|
||||||
|
**Done 2026-07-22:** Implemented `qonto_org_summary`, `qonto_list_transactions`,
|
||||||
|
and `qonto_cost_run_rate_hints` in `mcp_server.py`, all routed through
|
||||||
|
`CapabilityService` with `protocol="mcp"` (skipped `snapshot_bundle` — REST
|
||||||
|
already covers the composite read and it isn't a separate privilege).
|
||||||
|
`CapabilityService` now threads `protocol: ProtocolName` through `_execute`
|
||||||
|
and `_emit_audit` instead of hardcoding `"rest"`; added
|
||||||
|
`get_cost_run_rate_hints()` + `_build_cost_run_rate_hints_payload()` since
|
||||||
|
`cost_run_rate_hints` is its own policy capability, not only a snapshot
|
||||||
|
sub-field. Actor identity uses the same `X-Actor-*` header convention as
|
||||||
|
REST — `auth.py` now exposes a shared `actor_claims_from_headers()`, read
|
||||||
|
from the MCP `Context`'s underlying Starlette request when present, falling
|
||||||
|
back to defaults for stdio/no-request-context callers. Fixed a routing bug:
|
||||||
|
`FastMCP`'s default `streamable_http_path="/mcp"` plus mounting at `/mcp`
|
||||||
|
doubled to `/mcp/mcp` — set `streamable_http_path="/"` on the sub-app instead.
|
||||||
|
|
||||||
|
Verified: `tests/test_mcp_server.py` covers tool listing, an allow path
|
||||||
|
(`qonto_org_summary`, asserts redacted output + `protocol: "mcp"` audit
|
||||||
|
event), a deny path (`qonto_list_transactions` oversized `page_size` →
|
||||||
|
`ToolError`, `deny_reason: "arg_constraint"`), and `qonto_cost_run_rate_hints`.
|
||||||
|
Also ran a real end-to-end check with the `mcp` SDK's `streamablehttp_client`
|
||||||
|
against the live server (fixture-backed): tool list, `qonto_org_summary` call,
|
||||||
|
and confirmed `X-Actor-ID` header flows through to the audit log exactly like
|
||||||
|
REST. `pytest` → `25 passed`; `python3 -m compileall src tests scripts`.
|
||||||
|
|
||||||
## Task: Client auth and one shared config snippet
|
## Task: Client auth and one shared config snippet
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue