160 lines
5.4 KiB
Python
160 lines
5.4 KiB
Python
|
|
#!/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())
|