""" 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 ) 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 ' " "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 Authorization: Bearer <key>" if AUTH_ENABLED else "🔓 Open access — no API key configured (set CLIENT_API_KEY to lock it down)" ) tool_count = len(getattr(mcp._tool_manager, "_tools", {})) if hasattr(mcp, "_tool_manager") else "40+" html = textwrap.dedent(f"""\ EPANET MCP Server

💧 EPANET MCP Server

Water-distribution network modelling over MCP, powered by ePyT / EPANET. {tool_count} tools

{auth_line}

Endpoints

Streamable HTTP{base}/mcp — HuggingChat, Codex, Perplexity, Gemini, ChatGPT (dev mode) SSE{base}/sse — legacy clients / Claude Desktop via mcp-remote Health{base}/health

Quick connect (Streamable HTTP)

{{
  "mcpServers": {{
    "epanet": {{
      "url": "{base}/mcp",
      "headers": {{ "Authorization": "Bearer <YOUR_KEY>" }}
    }}
  }}
}}

Bundled ePyT benchmark networks + custom networks under /home/user/app/networks/ (e.g. net3.inp, ky4.inp, Net6.inp, BIWS.inp). Try: "List bundled networks, then load net3.inp and summarise it."

""") 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")