Spaces:
Sleeping
Sleeping
File size: 9,803 Bytes
5c027f4 c6a92ae 5c027f4 c6a92ae 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 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 | """
EPANET MCP Server β Hugging Face Space wrapper
==============================================
Wraps the `epanet-mcp-server` (ePyT/EPANET) FastMCP server and exposes it over
BOTH modern MCP transports simultaneously, behind an optional API key:
/mcp -> Streamable HTTP (HuggingChat, Codex, Perplexity, Gemini, ChatGPT dev-mode)
/sse -> HTTP + SSE (legacy clients / Claude Desktop via mcp-remote)
/messages/ -> SSE message sink
/ -> human-readable landing / status page
/health -> unauthenticated health probe (returns "ok")
Design notes
------------
* A single Starlette parent app drives the Streamable-HTTP session manager
lifespan (required) and routes each transport to its own sub-app via a pure
ASGI dispatcher β no Starlette Mount prefix stripping, no double `/mcp/mcp`.
* Auth is PURE ASGI middleware (not BaseHTTPMiddleware) so it never buffers or
breaks the streaming SSE / chunked responses.
* Stateful HTTP is kept ON: loaded networks live in an in-process registry and
must persist across tool calls within one MCP session (single uvicorn worker).
"""
from __future__ import annotations
import contextlib
import hmac
import os
import textwrap
from starlette.applications import Starlette
from starlette.responses import HTMLResponse, JSONResponse, PlainTextResponse
from starlette.routing import Route
from mcp.server.transport_security import TransportSecuritySettings
from epanet_mcp.server import mcp
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
# CLIENT_API_KEY : if set, every MCP request must present it as a bearer token
# (Authorization: Bearer <key>) or X-API-Key header.
# If unset AND REQUIRE_AUTH is not "true", the server runs OPEN
# (handy for ChatGPT connectors, which are OAuth/none only).
API_KEY = os.environ.get("CLIENT_API_KEY", "").strip()
REQUIRE_AUTH = os.environ.get("REQUIRE_AUTH", "").strip().lower() in {"1", "true", "yes"}
AUTH_ENABLED = bool(API_KEY) or REQUIRE_AUTH
# Keep session state so load_network -> get_summary -> run_sim share one registry.
mcp.settings.stateless_http = False
# HF Spaces sit behind a proxy, so the inbound Host header is the public
# *.hf.space domain. FastMCP's DNS-rebinding protection allow-lists only
# localhost by default and would reject every real request with 421
# "Invalid Host header". Disable it (HF terminates TLS and controls Host).
mcp.settings.transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=False,
)
streamable_app = mcp.streamable_http_app() # serves /mcp (+ owns session mgr lifespan)
sse_app = mcp.sse_app() # serves /sse + /messages/
PROTECTED_PREFIXES = ("/mcp", "/sse", "/messages")
# ---------------------------------------------------------------------------
# Pure-ASGI API key middleware
# ---------------------------------------------------------------------------
def _extract_key(headers: list[tuple[bytes, bytes]]) -> str | None:
hdr = {k.lower(): v for k, v in headers}
auth = hdr.get(b"authorization")
if auth:
text = auth.decode("latin-1").strip()
if text.lower().startswith("bearer "):
return text[7:].strip()
return text
xkey = hdr.get(b"x-api-key")
if xkey:
return xkey.decode("latin-1").strip()
return None
class APIKeyMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or not AUTH_ENABLED:
await self.app(scope, receive, send)
return
path = scope.get("path", "")
if not any(path == p or path.startswith(p + "/") or path.startswith(p)
for p in PROTECTED_PREFIXES):
await self.app(scope, receive, send)
return
presented = _extract_key(scope.get("headers", []))
ok = presented is not None and API_KEY != "" and hmac.compare_digest(presented, API_KEY)
if not ok:
resp = JSONResponse(
{"error": "unauthorized",
"detail": "Provide the API key via 'Authorization: Bearer <key>' "
"or the 'X-API-Key' header."},
status_code=401,
)
await resp(scope, receive, send)
return
await self.app(scope, receive, send)
# ---------------------------------------------------------------------------
# Transport dispatcher (pure ASGI)
# ---------------------------------------------------------------------------
class MCPDispatcher:
"""Route HTTP requests to the right transport sub-app by path prefix."""
def __init__(self, streamable, sse, fallback):
self.streamable = streamable
self.sse = sse
self.fallback = fallback
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
# lifespan is handled by the parent Starlette app's lifespan_context
await self.fallback(scope, receive, send)
return
path = scope.get("path", "")
# Normalise a trailing slash on the transport roots β Gemini / ChatGPT
# sometimes append one (e.g. "/mcp/"), but the sub-app route is "/mcp".
if path in ("/mcp/", "/sse/"):
path = path.rstrip("/")
scope = dict(scope)
scope["path"] = path
if path == "/mcp" or path.startswith("/mcp/"):
await self.streamable(scope, receive, send)
elif path == "/sse" or path.startswith("/messages"):
await self.sse(scope, receive, send)
else:
await self.fallback(scope, receive, send)
# ---------------------------------------------------------------------------
# Landing / health routes (the fallback app)
# ---------------------------------------------------------------------------
def _base_url(request) -> str:
# Honour HF's proxy headers so printed URLs use the public https host.
proto = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc))
return f"{proto}://{host}"
async def health(request):
return PlainTextResponse("ok")
async def landing(request):
base = _base_url(request)
auth_line = (
"π API key required β send <code>Authorization: Bearer <key></code>"
if AUTH_ENABLED else
"π Open access β no API key configured (set <code>CLIENT_API_KEY</code> to lock it down)"
)
tool_count = len(getattr(mcp._tool_manager, "_tools", {})) if hasattr(mcp, "_tool_manager") else "40+"
html = textwrap.dedent(f"""\
<!doctype html><html><head><meta charset="utf-8">
<title>EPANET MCP Server</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body{{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;max-width:760px;
margin:40px auto;padding:0 20px;line-height:1.55;color:#0f172a;background:#f8fafc}}
code{{background:#e2e8f0;padding:1px 5px;border-radius:4px;font-size:.9em}}
pre{{background:#0f172a;color:#e2e8f0;padding:14px 16px;border-radius:8px;overflow:auto;font-size:.85em}}
h1{{margin-bottom:.2em}} .sub{{color:#475569;margin-top:0}}
.grid{{display:grid;grid-template-columns:auto 1fr;gap:6px 16px;margin:12px 0}}
.k{{color:#0369a1;font-weight:600}} a{{color:#0369a1}}
.pill{{display:inline-block;background:#dbeafe;color:#1e40af;border-radius:999px;padding:2px 10px;font-size:.8em}}
</style></head><body>
<h1>π§ EPANET MCP Server</h1>
<p class="sub">Water-distribution network modelling over MCP, powered by
<a href="https://github.com/KIOS-Research/EPyT">ePyT</a> / EPANET.
<span class="pill">{tool_count} tools</span></p>
<p>{auth_line}</p>
<h3>Endpoints</h3>
<div class="grid">
<span class="k">Streamable HTTP</span><span><code>{base}/mcp</code> β HuggingChat, Codex, Perplexity, Gemini, ChatGPT (dev mode)</span>
<span class="k">SSE</span><span><code>{base}/sse</code> β legacy clients / Claude Desktop via <code>mcp-remote</code></span>
<span class="k">Health</span><span><code>{base}/health</code></span>
</div>
<h3>Quick connect (Streamable HTTP)</h3>
<pre>{{
"mcpServers": {{
"epanet": {{
"url": "{base}/mcp",
"headers": {{ "Authorization": "Bearer <YOUR_KEY>" }}
}}
}}
}}</pre>
<p>Bundled ePyT benchmark networks + custom networks under
<code>/home/user/app/networks/</code> (e.g. <code>net3.inp</code>, <code>ky4.inp</code>,
<code>Net6.inp</code>, <code>BIWS.inp</code>). Try:
<em>"List bundled networks, then load net3.inp and summarise it."</em></p>
</body></html>""")
return HTMLResponse(html)
fallback_app = Starlette(routes=[
Route("/", landing),
Route("/health", health),
])
dispatcher = MCPDispatcher(streamable_app, sse_app, fallback_app)
guarded = APIKeyMiddleware(dispatcher)
# ---------------------------------------------------------------------------
# Parent app: drives the Streamable-HTTP session-manager lifespan
# ---------------------------------------------------------------------------
@contextlib.asynccontextmanager
async def lifespan(app):
async with streamable_app.router.lifespan_context(app):
yield
app = Starlette(lifespan=lifespan)
app.mount("/", guarded)
if __name__ == "__main__":
import uvicorn
port = int(os.environ.get("PORT", "7860"))
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
|