razaali10 commited on
Commit
5c027f4
·
verified ·
1 Parent(s): 942165e

Upload 7 files

Browse files
Files changed (6) hide show
  1. CLIENTS.md +91 -0
  2. Dockerfile +24 -0
  3. README.md +68 -0
  4. app.py +226 -0
  5. requirements.txt +11 -0
  6. smoke_test.py +134 -0
CLIENTS.md ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Connecting clients
2
+
3
+ Base URL: `https://<owner>-<space-name>.hf.space`
4
+ (shown on the Space page once it's running — e.g. `https://razaali10-epanet-mcp-server.hf.space`)
5
+
6
+ Two endpoints:
7
+ - **Streamable HTTP** → `…/mcp` (preferred by all five clients below)
8
+ - **SSE** → `…/sse` (legacy fallback; Claude Desktop via `mcp-remote`)
9
+
10
+ If you set the `CLIENT_API_KEY` Space secret, every client must send
11
+ `Authorization: Bearer <key>` (or `X-API-Key: <key>`).
12
+
13
+ | Client | Transport | Static API-key header? | Notes |
14
+ |--------|-----------|------------------------|-------|
15
+ | HuggingChat | Streamable HTTP | ✅ | Add under Settings → add MCP server |
16
+ | Codex | Streamable HTTP | ✅ | `http_headers` in `config.toml` |
17
+ | Perplexity | Streamable HTTP | ✅ (API Key / header) | Pro/Max/Enterprise only |
18
+ | Gemini CLI | Streamable HTTP | ✅ | `httpUrl` + `headers` in `settings.json` |
19
+ | ChatGPT | Streamable HTTP | ❌ (OAuth/None only) | **Run the Space with no key**, select "No authentication" |
20
+
21
+ > ⚠️ **ChatGPT** cannot send a static API key. To use it, leave `CLIENT_API_KEY`
22
+ > unset (open mode). If you need the key on for the other four, that's fine —
23
+ > just skip ChatGPT, or run a second open instance for it.
24
+
25
+ ---
26
+
27
+ ## HuggingChat (HF ChatUI)
28
+ Settings → Tools / MCP → add server:
29
+ - **URL**: `https://<space>.hf.space/mcp`
30
+ - **Header**: `Authorization: Bearer <YOUR_KEY>`
31
+
32
+ ## Codex (`~/.codex/config.toml`)
33
+ ```toml
34
+ [mcp_servers.epanet]
35
+ url = "https://<space>.hf.space/mcp"
36
+ http_headers = { Authorization = "Bearer <YOUR_KEY>" }
37
+ ```
38
+
39
+ ## Perplexity (Pro / Max / Enterprise)
40
+ Account settings → Connectors → **+ Custom connector → Remote**:
41
+ - **MCP Server URL**: `https://<space>.hf.space/mcp`
42
+ - **Transport**: Streamable HTTP
43
+ - **Authentication**: API Key → send as header `X-API-Key: <YOUR_KEY>`
44
+ (or `Authorization: Bearer <YOUR_KEY>`)
45
+
46
+ ## Gemini CLI (`~/.gemini/settings.json`)
47
+ ```json
48
+ {
49
+ "mcpServers": {
50
+ "epanet": {
51
+ "httpUrl": "https://<space>.hf.space/mcp",
52
+ "headers": { "Authorization": "Bearer <YOUR_KEY>" },
53
+ "timeout": 30000
54
+ }
55
+ }
56
+ }
57
+ ```
58
+ Or one-liner:
59
+ ```bash
60
+ gemini mcp add --transport http \
61
+ --header "Authorization: Bearer <YOUR_KEY>" \
62
+ epanet https://<space>.hf.space/mcp
63
+ ```
64
+
65
+ ## ChatGPT (Developer Mode — Plus/Pro/Business/Enterprise/Edu)
66
+ Requires the Space in **open mode** (no `CLIENT_API_KEY`).
67
+ Settings → Apps/Connectors → Advanced → **Developer mode** → Create app:
68
+ - **URL**: `https://<space>.hf.space/mcp`
69
+ - **Transport**: Streamable HTTP
70
+ - **Authentication**: No authentication
71
+
72
+ ## Claude Desktop (SSE via mcp-remote)
73
+ ```json
74
+ {
75
+ "mcpServers": {
76
+ "epanet": {
77
+ "command": "npx",
78
+ "args": ["-y", "mcp-remote", "https://<space>.hf.space/sse",
79
+ "--header", "Authorization: Bearer <YOUR_KEY>"]
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ ---
86
+
87
+ ## First things to try
88
+ 1. "List the bundled networks."
89
+ 2. "Load net3.inp from /home/user/app/networks/ and summarise it."
90
+ 3. "Run a hydraulic simulation on net3 and tell me the lowest-pressure node at hour 12."
91
+ 4. "Simulate a 20% burst on a pipe in net3 and compare pressures to baseline."
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ # ePyT loads the EPANET toolkit shared library; these keep it happy on slim.
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ libgomp1 \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ # HF Spaces run as uid 1000 / user "user".
9
+ RUN useradd -m -u 1000 user
10
+ USER user
11
+ ENV PATH="/home/user/.local/bin:$PATH" \
12
+ HOME=/home/user \
13
+ PORT=7860
14
+
15
+ WORKDIR /home/user/app
16
+
17
+ COPY --chown=user requirements.txt .
18
+ RUN pip install --no-cache-dir --user -r requirements.txt
19
+
20
+ COPY --chown=user app.py smoke_test.py ./
21
+ COPY --chown=user networks ./networks
22
+
23
+ EXPOSE 7860
24
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: EPANET MCP Server
3
+ emoji: 💧
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ short_description: EPANET/ePyT water-network modelling over MCP (HTTP + SSE)
11
+ ---
12
+
13
+ # 💧 EPANET MCP Server
14
+
15
+ A remote [Model Context Protocol](https://modelcontextprotocol.io) server that exposes
16
+ EPANET water-distribution modelling — powered by [ePyT](https://github.com/KIOS-Research/EPyT) —
17
+ to any MCP-capable client. Load `.inp` models, run hydraulic/quality simulations, modify
18
+ parameters, and generate what-if scenarios in natural language.
19
+
20
+ It wraps the upstream [`epanet-mcp-server`](https://pypi.org/project/epanet-mcp-server/)
21
+ (44 tools) and serves it over **both** modern transports at once, behind an optional API key.
22
+
23
+ ## Endpoints
24
+
25
+ | Transport | Path | Clients |
26
+ |-----------|------|---------|
27
+ | Streamable HTTP | `/mcp` | HuggingChat, Codex, Perplexity, Gemini, ChatGPT (dev mode) |
28
+ | HTTP + SSE | `/sse` | Claude Desktop (via `mcp-remote`), other legacy clients |
29
+ | Health probe | `/health` | uptime checks (no auth) |
30
+
31
+ Base URL once deployed: `https://<owner>-<space-name>.hf.space`
32
+
33
+ ## Authentication
34
+
35
+ Set a Space **secret** named `CLIENT_API_KEY`. When present, every request to `/mcp`,
36
+ `/sse`, and `/messages/` must send it:
37
+
38
+ ```
39
+ Authorization: Bearer <YOUR_KEY>
40
+ ```
41
+
42
+ (`X-API-Key: <YOUR_KEY>` is also accepted.) Leave the secret unset to run open — useful for
43
+ ChatGPT connectors, which only support OAuth or no-auth. `/` and `/health` are always public.
44
+
45
+ ## Networks
46
+
47
+ ePyT ships 52 benchmark networks (Net1/2/3, Hanoi, Anytown, L-TOWN, Balerma, …) — call
48
+ `list_bundled_networks` to see them. Custom networks are bundled under
49
+ `/home/user/app/networks/` and loaded by absolute path, e.g.:
50
+
51
+ ```
52
+ load_network("/home/user/app/networks/net3.inp", "net3")
53
+ ```
54
+
55
+ Bundled here: `net3.inp`, `net3_PDD.inp`, `ky4.inp`, `ky10.inp`, `Net6.inp`, `BIWS.inp`.
56
+
57
+ ## Smoke test
58
+
59
+ `smoke_test.py` runs the full MCP handshake (initialize → tools/list → tools/call) over
60
+ both transports and checks the auth gate:
61
+
62
+ ```
63
+ python smoke_test.py --base-url https://<owner>-<space-name>.hf.space --api-key <YOUR_KEY>
64
+ ```
65
+
66
+ See `CLIENTS.md` for copy-paste connection configs for each client.
67
+
68
+ Built on `epanet-mcp-server` by Applied-AI-Eurecat (MIT).
app.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ EPANET MCP Server — Hugging Face Space wrapper
3
+ ==============================================
4
+
5
+ Wraps the `epanet-mcp-server` (ePyT/EPANET) FastMCP server and exposes it over
6
+ BOTH modern MCP transports simultaneously, behind an optional API key:
7
+
8
+ /mcp -> Streamable HTTP (HuggingChat, Codex, Perplexity, Gemini, ChatGPT dev-mode)
9
+ /sse -> HTTP + SSE (legacy clients / Claude Desktop via mcp-remote)
10
+ /messages/ -> SSE message sink
11
+ / -> human-readable landing / status page
12
+ /health -> unauthenticated health probe (returns "ok")
13
+
14
+ Design notes
15
+ ------------
16
+ * A single Starlette parent app drives the Streamable-HTTP session manager
17
+ lifespan (required) and routes each transport to its own sub-app via a pure
18
+ ASGI dispatcher — no Starlette Mount prefix stripping, no double `/mcp/mcp`.
19
+ * Auth is PURE ASGI middleware (not BaseHTTPMiddleware) so it never buffers or
20
+ breaks the streaming SSE / chunked responses.
21
+ * Stateful HTTP is kept ON: loaded networks live in an in-process registry and
22
+ must persist across tool calls within one MCP session (single uvicorn worker).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import contextlib
28
+ import hmac
29
+ import os
30
+ import textwrap
31
+
32
+ from starlette.applications import Starlette
33
+ from starlette.responses import HTMLResponse, JSONResponse, PlainTextResponse
34
+ from starlette.routing import Route
35
+
36
+ from epanet_mcp.server import mcp
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Configuration
40
+ # ---------------------------------------------------------------------------
41
+ # CLIENT_API_KEY : if set, every MCP request must present it as a bearer token
42
+ # (Authorization: Bearer <key>) or X-API-Key header.
43
+ # If unset AND REQUIRE_AUTH is not "true", the server runs OPEN
44
+ # (handy for ChatGPT connectors, which are OAuth/none only).
45
+ API_KEY = os.environ.get("CLIENT_API_KEY", "").strip()
46
+ REQUIRE_AUTH = os.environ.get("REQUIRE_AUTH", "").strip().lower() in {"1", "true", "yes"}
47
+ AUTH_ENABLED = bool(API_KEY) or REQUIRE_AUTH
48
+
49
+ # Keep session state so load_network -> get_summary -> run_sim share one registry.
50
+ mcp.settings.stateless_http = False
51
+
52
+ streamable_app = mcp.streamable_http_app() # serves /mcp (+ owns session mgr lifespan)
53
+ sse_app = mcp.sse_app() # serves /sse + /messages/
54
+
55
+ PROTECTED_PREFIXES = ("/mcp", "/sse", "/messages")
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Pure-ASGI API key middleware
60
+ # ---------------------------------------------------------------------------
61
+ def _extract_key(headers: list[tuple[bytes, bytes]]) -> str | None:
62
+ hdr = {k.lower(): v for k, v in headers}
63
+ auth = hdr.get(b"authorization")
64
+ if auth:
65
+ text = auth.decode("latin-1").strip()
66
+ if text.lower().startswith("bearer "):
67
+ return text[7:].strip()
68
+ return text
69
+ xkey = hdr.get(b"x-api-key")
70
+ if xkey:
71
+ return xkey.decode("latin-1").strip()
72
+ return None
73
+
74
+
75
+ class APIKeyMiddleware:
76
+ def __init__(self, app):
77
+ self.app = app
78
+
79
+ async def __call__(self, scope, receive, send):
80
+ if scope["type"] != "http" or not AUTH_ENABLED:
81
+ await self.app(scope, receive, send)
82
+ return
83
+
84
+ path = scope.get("path", "")
85
+ if not any(path == p or path.startswith(p + "/") or path.startswith(p)
86
+ for p in PROTECTED_PREFIXES):
87
+ await self.app(scope, receive, send)
88
+ return
89
+
90
+ presented = _extract_key(scope.get("headers", []))
91
+ ok = presented is not None and API_KEY != "" and hmac.compare_digest(presented, API_KEY)
92
+ if not ok:
93
+ resp = JSONResponse(
94
+ {"error": "unauthorized",
95
+ "detail": "Provide the API key via 'Authorization: Bearer <key>' "
96
+ "or the 'X-API-Key' header."},
97
+ status_code=401,
98
+ )
99
+ await resp(scope, receive, send)
100
+ return
101
+
102
+ await self.app(scope, receive, send)
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Transport dispatcher (pure ASGI)
107
+ # ---------------------------------------------------------------------------
108
+ class MCPDispatcher:
109
+ """Route HTTP requests to the right transport sub-app by path prefix."""
110
+
111
+ def __init__(self, streamable, sse, fallback):
112
+ self.streamable = streamable
113
+ self.sse = sse
114
+ self.fallback = fallback
115
+
116
+ async def __call__(self, scope, receive, send):
117
+ if scope["type"] != "http":
118
+ # lifespan is handled by the parent Starlette app's lifespan_context
119
+ await self.fallback(scope, receive, send)
120
+ return
121
+ path = scope.get("path", "")
122
+ # Normalise a trailing slash on the transport roots — Gemini / ChatGPT
123
+ # sometimes append one (e.g. "/mcp/"), but the sub-app route is "/mcp".
124
+ if path in ("/mcp/", "/sse/"):
125
+ path = path.rstrip("/")
126
+ scope = dict(scope)
127
+ scope["path"] = path
128
+ if path == "/mcp" or path.startswith("/mcp/"):
129
+ await self.streamable(scope, receive, send)
130
+ elif path == "/sse" or path.startswith("/messages"):
131
+ await self.sse(scope, receive, send)
132
+ else:
133
+ await self.fallback(scope, receive, send)
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Landing / health routes (the fallback app)
138
+ # ---------------------------------------------------------------------------
139
+ def _base_url(request) -> str:
140
+ # Honour HF's proxy headers so printed URLs use the public https host.
141
+ proto = request.headers.get("x-forwarded-proto", request.url.scheme)
142
+ host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc))
143
+ return f"{proto}://{host}"
144
+
145
+
146
+ async def health(request):
147
+ return PlainTextResponse("ok")
148
+
149
+
150
+ async def landing(request):
151
+ base = _base_url(request)
152
+ auth_line = (
153
+ "🔒 API key required — send <code>Authorization: Bearer &lt;key&gt;</code>"
154
+ if AUTH_ENABLED else
155
+ "🔓 Open access — no API key configured (set <code>CLIENT_API_KEY</code> to lock it down)"
156
+ )
157
+ tool_count = len(getattr(mcp._tool_manager, "_tools", {})) if hasattr(mcp, "_tool_manager") else "40+"
158
+ html = textwrap.dedent(f"""\
159
+ <!doctype html><html><head><meta charset="utf-8">
160
+ <title>EPANET MCP Server</title>
161
+ <meta name="viewport" content="width=device-width, initial-scale=1">
162
+ <style>
163
+ body{{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;max-width:760px;
164
+ margin:40px auto;padding:0 20px;line-height:1.55;color:#0f172a;background:#f8fafc}}
165
+ code{{background:#e2e8f0;padding:1px 5px;border-radius:4px;font-size:.9em}}
166
+ pre{{background:#0f172a;color:#e2e8f0;padding:14px 16px;border-radius:8px;overflow:auto;font-size:.85em}}
167
+ h1{{margin-bottom:.2em}} .sub{{color:#475569;margin-top:0}}
168
+ .grid{{display:grid;grid-template-columns:auto 1fr;gap:6px 16px;margin:12px 0}}
169
+ .k{{color:#0369a1;font-weight:600}} a{{color:#0369a1}}
170
+ .pill{{display:inline-block;background:#dbeafe;color:#1e40af;border-radius:999px;padding:2px 10px;font-size:.8em}}
171
+ </style></head><body>
172
+ <h1>💧 EPANET MCP Server</h1>
173
+ <p class="sub">Water-distribution network modelling over MCP, powered by
174
+ <a href="https://github.com/KIOS-Research/EPyT">ePyT</a> / EPANET.
175
+ <span class="pill">{tool_count} tools</span></p>
176
+ <p>{auth_line}</p>
177
+ <h3>Endpoints</h3>
178
+ <div class="grid">
179
+ <span class="k">Streamable HTTP</span><span><code>{base}/mcp</code> — HuggingChat, Codex, Perplexity, Gemini, ChatGPT (dev mode)</span>
180
+ <span class="k">SSE</span><span><code>{base}/sse</code> — legacy clients / Claude Desktop via <code>mcp-remote</code></span>
181
+ <span class="k">Health</span><span><code>{base}/health</code></span>
182
+ </div>
183
+ <h3>Quick connect (Streamable HTTP)</h3>
184
+ <pre>{{
185
+ "mcpServers": {{
186
+ "epanet": {{
187
+ "url": "{base}/mcp",
188
+ "headers": {{ "Authorization": "Bearer &lt;YOUR_KEY&gt;" }}
189
+ }}
190
+ }}
191
+ }}</pre>
192
+ <p>Bundled ePyT benchmark networks + custom networks under
193
+ <code>/home/user/app/networks/</code> (e.g. <code>net3.inp</code>, <code>ky4.inp</code>,
194
+ <code>Net6.inp</code>, <code>BIWS.inp</code>). Try:
195
+ <em>"List bundled networks, then load net3.inp and summarise it."</em></p>
196
+ </body></html>""")
197
+ return HTMLResponse(html)
198
+
199
+
200
+ fallback_app = Starlette(routes=[
201
+ Route("/", landing),
202
+ Route("/health", health),
203
+ ])
204
+
205
+ dispatcher = MCPDispatcher(streamable_app, sse_app, fallback_app)
206
+ guarded = APIKeyMiddleware(dispatcher)
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Parent app: drives the Streamable-HTTP session-manager lifespan
211
+ # ---------------------------------------------------------------------------
212
+ @contextlib.asynccontextmanager
213
+ async def lifespan(app):
214
+ async with streamable_app.router.lifespan_context(app):
215
+ yield
216
+
217
+
218
+ app = Starlette(lifespan=lifespan)
219
+ app.mount("/", guarded)
220
+
221
+
222
+ if __name__ == "__main__":
223
+ import uvicorn
224
+
225
+ port = int(os.environ.get("PORT", "7860"))
226
+ uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # EPANET MCP server (ePyT) + its deps
2
+ epanet-mcp-server==0.1.2
3
+ epyt==2.3.5.2
4
+
5
+ # MCP + ASGI stack (transports, auth wrapper, landing page)
6
+ mcp==1.28.1
7
+ starlette==1.3.1
8
+ uvicorn==0.50.0
9
+
10
+ # Smoke-test client
11
+ httpx==0.28.1
smoke_test.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Smoke test for the EPANET MCP Space.
3
+
4
+ Runs the full MCP handshake (initialize -> tools/list -> tools/call) against a
5
+ running server over BOTH transports, and verifies the API-key gate. This is the
6
+ transport-level proof that every target client can connect:
7
+
8
+ Streamable HTTP -> HuggingChat, Codex, Perplexity, Gemini, ChatGPT (dev)
9
+ SSE -> Claude Desktop (via mcp-remote) and other legacy clients
10
+
11
+ Usage:
12
+ python smoke_test.py --base-url http://127.0.0.1:7860 [--api-key KEY]
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import asyncio
19
+ import sys
20
+
21
+ import httpx
22
+ from mcp import ClientSession
23
+ from mcp.client.sse import sse_client
24
+ from mcp.client.streamable_http import streamablehttp_client
25
+
26
+ PASS, FAIL = "✅", "❌"
27
+ results: list[tuple[str, bool, str]] = []
28
+
29
+
30
+ def record(name: str, ok: bool, detail: str = "") -> None:
31
+ results.append((name, ok, detail))
32
+ print(f" {PASS if ok else FAIL} {name}" + (f" — {detail}" if detail else ""))
33
+
34
+
35
+ def _headers(api_key: str | None) -> dict:
36
+ return {"Authorization": f"Bearer {api_key}"} if api_key else {}
37
+
38
+
39
+ async def exercise(session: ClientSession, transport: str) -> None:
40
+ await session.initialize()
41
+ record(f"[{transport}] initialize", True)
42
+
43
+ tools = (await session.list_tools()).tools
44
+ names = {t.name for t in tools}
45
+ record(f"[{transport}] tools/list", len(tools) > 0, f"{len(tools)} tools")
46
+
47
+ expected = {"load_network", "get_network_summary", "run_hydraulic_simulation",
48
+ "list_bundled_networks", "create_leakage_event"}
49
+ missing = expected - names
50
+ record(f"[{transport}] core tools present", not missing,
51
+ "all present" if not missing else f"missing {missing}")
52
+
53
+ # Functional round-trip: list bundled -> load Net1 -> summarise
54
+ r = await session.call_tool("list_bundled_networks", {})
55
+ record(f"[{transport}] call list_bundled_networks", not r.isError)
56
+
57
+ r = await session.call_tool("load_network", {"path": "Net1.inp", "network_id": f"smoke_{transport}"})
58
+ record(f"[{transport}] call load_network(Net1)", not r.isError)
59
+
60
+ r = await session.call_tool("get_network_summary", {"network_id": f"smoke_{transport}"})
61
+ text = (r.content[0].text if r.content else "")
62
+ ok = (not r.isError) and ("junctions" in text or "nodes" in text)
63
+ record(f"[{transport}] call get_network_summary", ok,
64
+ "summary returned" if ok else "unexpected payload")
65
+
66
+
67
+ async def test_streamable(base_url: str, api_key: str | None) -> None:
68
+ print("\n▶ Streamable HTTP (/mcp)")
69
+ url = f"{base_url}/mcp"
70
+ try:
71
+ async with streamablehttp_client(url, headers=_headers(api_key)) as (r, w, _):
72
+ async with ClientSession(r, w) as session:
73
+ await exercise(session, "http")
74
+ except Exception as e: # noqa: BLE001
75
+ record("[http] connection", False, repr(e))
76
+
77
+
78
+ async def test_sse(base_url: str, api_key: str | None) -> None:
79
+ print("\n▶ SSE (/sse)")
80
+ url = f"{base_url}/sse"
81
+ try:
82
+ async with sse_client(url, headers=_headers(api_key)) as (r, w):
83
+ async with ClientSession(r, w) as session:
84
+ await exercise(session, "sse")
85
+ except Exception as e: # noqa: BLE001
86
+ record("[sse] connection", False, repr(e))
87
+
88
+
89
+ async def test_health_and_auth(base_url: str, api_key: str | None) -> None:
90
+ print("\n▶ Health + auth gate")
91
+ async with httpx.AsyncClient(timeout=15) as c:
92
+ try:
93
+ resp = await c.get(f"{base_url}/health")
94
+ record("health returns 200/ok", resp.status_code == 200 and resp.text.strip() == "ok")
95
+ except Exception as e: # noqa: BLE001
96
+ record("health probe", False, repr(e))
97
+
98
+ if api_key:
99
+ # No key -> must be 401 on a protected endpoint
100
+ try:
101
+ resp = await c.post(f"{base_url}/mcp",
102
+ json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
103
+ headers={"Accept": "application/json, text/event-stream"})
104
+ record("unauthenticated /mcp rejected (401)", resp.status_code == 401,
105
+ f"status {resp.status_code}")
106
+ except Exception as e: # noqa: BLE001
107
+ record("unauthenticated /mcp rejected", False, repr(e))
108
+ else:
109
+ record("auth gate", True, "open mode (no key configured) — skipped")
110
+
111
+
112
+ async def main() -> int:
113
+ ap = argparse.ArgumentParser()
114
+ ap.add_argument("--base-url", default="http://127.0.0.1:7860")
115
+ ap.add_argument("--api-key", default=None)
116
+ args = ap.parse_args()
117
+ base = args.base_url.rstrip("/")
118
+
119
+ print(f"Target: {base} auth={'on' if args.api_key else 'off'}")
120
+ await test_health_and_auth(base, args.api_key)
121
+ await test_streamable(base, args.api_key)
122
+ await test_sse(base, args.api_key)
123
+
124
+ passed = sum(1 for _, ok, _ in results if ok)
125
+ total = len(results)
126
+ print(f"\n{'='*48}\nRESULT: {passed}/{total} checks passed")
127
+ failed = [n for n, ok, _ in results if not ok]
128
+ if failed:
129
+ print("Failed:", ", ".join(failed))
130
+ return 0 if passed == total else 1
131
+
132
+
133
+ if __name__ == "__main__":
134
+ sys.exit(asyncio.run(main()))