qonto-assistant/scripts/smoke_mcp.py

160 lines
5.4 KiB
Python
Raw Normal View History

#!/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())