Spaces:
Running
Running
Upload 7 files
Browse files- CLIENTS.md +91 -0
- Dockerfile +24 -0
- README.md +68 -0
- app.py +226 -0
- requirements.txt +11 -0
- 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 <key></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 <YOUR_KEY>" }}
|
| 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()))
|