#!/usr/bin/env python3 from __future__ import annotations import argparse import asyncio import json import os import secrets import socket import subprocess import sys import time import urllib.error import urllib.request from pathlib import Path from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Run a local MCP smoke test for qonto-assistant.") parser.add_argument( "--python", default=sys.executable, help="Python interpreter used to start qonto_assistant.main", ) parser.add_argument( "--fixture-dir", default=str(Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "qonto"), help="Fixture directory containing organization.json and transactions.json", ) parser.add_argument( "--startup-timeout", type=float, default=15.0, help="Seconds to wait for the local service to start", ) return parser.parse_args() def free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) return int(sock.getsockname()[1]) def wait_for_health(base_url: str, *, timeout_seconds: float) -> None: deadline = time.monotonic() + timeout_seconds last_error: Exception | None = None while time.monotonic() < deadline: try: request = urllib.request.Request(f"{base_url}/v1/health") with urllib.request.urlopen(request, timeout=5) as response: json.load(response) return except Exception as exc: # noqa: BLE001 last_error = exc time.sleep(0.25) raise RuntimeError(f"Timed out waiting for {base_url}/v1/health: {last_error}") async def run_mcp_checks(base_url: str, *, token: str) -> dict[str, object]: headers = { "Authorization": f"Bearer {token}", "X-Actor-ID": "smoke", "X-Tenant-ID": "binky", } async with streamablehttp_client(f"{base_url}/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() tools = {tool.name for tool in (await session.list_tools()).tools} expected_tools = { "qonto_ping", "qonto_org_summary", "qonto_list_transactions", "qonto_cost_run_rate_hints", } assert tools == expected_tools, f"unexpected tool catalog: {tools}" ping = await session.call_tool("qonto_ping", {}) assert ping.isError is False assert ping.structuredContent["status"] == "ok" org_summary = await session.call_tool("qonto_org_summary", {}) assert org_summary.isError is False assert org_summary.structuredContent["organization"]["name"] == "Binky Hedgehog GmbH" assert org_summary.structuredContent["accounts"][0]["iban_last4"] == "6810" transactions = await session.call_tool( "qonto_list_transactions", {"window_days": 31, "page_size": 50} ) assert transactions.isError is False cost_hints = await session.call_tool( "qonto_cost_run_rate_hints", {"window_days": 90, "page_size": 50} ) assert cost_hints.isError is False assert cost_hints.structuredContent["cost_run_rate_hints"]["recurring_debits"][0]["label"] == "HUB31" # Deny behavior for an out-of-catalog / spend-shaped tool name: # this tool was never registered, so the low-level protocol # handler must reject it as a normal error result, never a # crash and never a policy bypass. denied = await session.call_tool("qonto_transfer_funds", {}) assert denied.isError is True return { "tools": sorted(tools), "org_summary": org_summary.structuredContent, "cost_hints": cost_hints.structuredContent, "out_of_catalog_denied": denied.isError, } def main() -> int: args = parse_args() repo_root = Path(__file__).resolve().parents[1] port = free_port() base_url = f"http://127.0.0.1:{port}" token = secrets.token_urlsafe(16) env = os.environ.copy() env["PYTHONPATH"] = str(repo_root / "src") env["QONTO_ASSISTANT_HOST"] = "127.0.0.1" env["QONTO_ASSISTANT_PORT"] = str(port) env["QONTO_FIXTURE_DIR"] = str(Path(args.fixture_dir).resolve()) env["QONTO_ASSISTANT_MCP_TOKEN"] = token process = subprocess.Popen( # noqa: S603 [args.python, "-m", "qonto_assistant.main"], cwd=repo_root, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) try: wait_for_health(base_url, timeout_seconds=args.startup_timeout) result = asyncio.run(run_mcp_checks(base_url, token=token)) print(json.dumps(result, indent=2)) return 0 finally: process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=5) if process.stdout is not None: output = process.stdout.read().strip() if output: sys.stderr.write(output + "\n") if __name__ == "__main__": raise SystemExit(main())