File size: 5,195 Bytes
5c027f4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
"""
Smoke test for the EPANET MCP Space.

Runs the full MCP handshake (initialize -> tools/list -> tools/call) against a
running server over BOTH transports, and verifies the API-key gate. This is the
transport-level proof that every target client can connect:

    Streamable HTTP  -> HuggingChat, Codex, Perplexity, Gemini, ChatGPT (dev)
    SSE              -> Claude Desktop (via mcp-remote) and other legacy clients

Usage:
    python smoke_test.py --base-url http://127.0.0.1:7860 [--api-key KEY]
"""

from __future__ import annotations

import argparse
import asyncio
import sys

import httpx
from mcp import ClientSession
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamablehttp_client

PASS, FAIL = "✅", "❌"
results: list[tuple[str, bool, str]] = []


def record(name: str, ok: bool, detail: str = "") -> None:
    results.append((name, ok, detail))
    print(f"  {PASS if ok else FAIL} {name}" + (f" — {detail}" if detail else ""))


def _headers(api_key: str | None) -> dict:
    return {"Authorization": f"Bearer {api_key}"} if api_key else {}


async def exercise(session: ClientSession, transport: str) -> None:
    await session.initialize()
    record(f"[{transport}] initialize", True)

    tools = (await session.list_tools()).tools
    names = {t.name for t in tools}
    record(f"[{transport}] tools/list", len(tools) > 0, f"{len(tools)} tools")

    expected = {"load_network", "get_network_summary", "run_hydraulic_simulation",
                "list_bundled_networks", "create_leakage_event"}
    missing = expected - names
    record(f"[{transport}] core tools present", not missing,
           "all present" if not missing else f"missing {missing}")

    # Functional round-trip: list bundled -> load Net1 -> summarise
    r = await session.call_tool("list_bundled_networks", {})
    record(f"[{transport}] call list_bundled_networks", not r.isError)

    r = await session.call_tool("load_network", {"path": "Net1.inp", "network_id": f"smoke_{transport}"})
    record(f"[{transport}] call load_network(Net1)", not r.isError)

    r = await session.call_tool("get_network_summary", {"network_id": f"smoke_{transport}"})
    text = (r.content[0].text if r.content else "")
    ok = (not r.isError) and ("junctions" in text or "nodes" in text)
    record(f"[{transport}] call get_network_summary", ok,
           "summary returned" if ok else "unexpected payload")


async def test_streamable(base_url: str, api_key: str | None) -> None:
    print("\n▶ Streamable HTTP  (/mcp)")
    url = f"{base_url}/mcp"
    try:
        async with streamablehttp_client(url, headers=_headers(api_key)) as (r, w, _):
            async with ClientSession(r, w) as session:
                await exercise(session, "http")
    except Exception as e:  # noqa: BLE001
        record("[http] connection", False, repr(e))


async def test_sse(base_url: str, api_key: str | None) -> None:
    print("\n▶ SSE  (/sse)")
    url = f"{base_url}/sse"
    try:
        async with sse_client(url, headers=_headers(api_key)) as (r, w):
            async with ClientSession(r, w) as session:
                await exercise(session, "sse")
    except Exception as e:  # noqa: BLE001
        record("[sse] connection", False, repr(e))


async def test_health_and_auth(base_url: str, api_key: str | None) -> None:
    print("\n▶ Health + auth gate")
    async with httpx.AsyncClient(timeout=15) as c:
        try:
            resp = await c.get(f"{base_url}/health")
            record("health returns 200/ok", resp.status_code == 200 and resp.text.strip() == "ok")
        except Exception as e:  # noqa: BLE001
            record("health probe", False, repr(e))

        if api_key:
            # No key -> must be 401 on a protected endpoint
            try:
                resp = await c.post(f"{base_url}/mcp",
                                    json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
                                    headers={"Accept": "application/json, text/event-stream"})
                record("unauthenticated /mcp rejected (401)", resp.status_code == 401,
                       f"status {resp.status_code}")
            except Exception as e:  # noqa: BLE001
                record("unauthenticated /mcp rejected", False, repr(e))
        else:
            record("auth gate", True, "open mode (no key configured) — skipped")


async def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--base-url", default="http://127.0.0.1:7860")
    ap.add_argument("--api-key", default=None)
    args = ap.parse_args()
    base = args.base_url.rstrip("/")

    print(f"Target: {base}   auth={'on' if args.api_key else 'off'}")
    await test_health_and_auth(base, args.api_key)
    await test_streamable(base, args.api_key)
    await test_sse(base, args.api_key)

    passed = sum(1 for _, ok, _ in results if ok)
    total = len(results)
    print(f"\n{'='*48}\nRESULT: {passed}/{total} checks passed")
    failed = [n for n, ok, _ in results if not ok]
    if failed:
        print("Failed:", ", ".join(failed))
    return 0 if passed == total else 1


if __name__ == "__main__":
    sys.exit(asyncio.run(main()))