""" Streamable-HTTP wrapper that turns the (stdio-only) KWR EPANET MCP server into a remote endpoint for Hugging Face Spaces / HuggingChat. The upstream package (src/mcp_server_epanet, GPL-3.0) is imported UNMODIFIED. This file only adds transport: it serves FastMCP.streamable_http_app() at /mcp, plus a landing page (/), a health probe (/health), and optional bearer auth. MCP endpoint to paste into HuggingChat: https://-.hf.space/mcp """ import os import json # --------------------------------------------------------------------------- # 1. Working directory. Upstream server.py does os.makedirs("Networks") / # os.makedirs("Modified") with RELATIVE paths at import time, so we chdir # into this repo root first. ./Networks ships the .inp files; ./Modified is # created at runtime. # --------------------------------------------------------------------------- APP_DIR = os.path.dirname(os.path.abspath(__file__)) os.chdir(APP_DIR) os.makedirs(os.path.join(APP_DIR, "Networks"), exist_ok=True) os.makedirs(os.path.join(APP_DIR, "Modified"), exist_ok=True) # matplotlib (Agg, headless) needs a writable config dir on the Space os.environ.setdefault("MPLCONFIGDIR", os.path.join(APP_DIR, ".mpl")) os.makedirs(os.environ["MPLCONFIGDIR"], exist_ok=True) # --------------------------------------------------------------------------- # 2. Import the unmodified FastMCP instance and bind it to 0.0.0.0:PORT. # --------------------------------------------------------------------------- from mcp_server_epanet.server import mcp # noqa: E402 (must follow chdir) PORT = int(os.environ.get("PORT", 7860)) mcp.settings.host = "0.0.0.0" mcp.settings.port = PORT # FastMCP's streamable_http_path defaults to "/mcp". # 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 the Host). from mcp.server.transport_security import TransportSecuritySettings # noqa: E402 mcp.settings.transport_security = TransportSecuritySettings( enable_dns_rebinding_protection=False, ) # Optional shared secret. Set a Space secret named MCP_API_KEY to require # `Authorization: Bearer ` on /mcp. Leave unset for an open endpoint. API_KEY = os.environ.get("MCP_API_KEY", "").strip() _NETWORKS = sorted( f for f in os.listdir(os.path.join(APP_DIR, "Networks")) if f.lower().endswith(".inp") ) LANDING_HTML = f""" EPANET MCP Server

๐Ÿšฐ EPANET MCP Server

KWR's conversational hydraulic modelling server, over the Model Context Protocol.

Streamable HTTP   endpoint: /mcp  ยท  auth: {"Bearer token required" if API_KEY else "open (no key set)"}


Connect a client

Point any Streamable-HTTP MCP client (e.g. HuggingChat) at:

{{origin}}/mcp

Tools

  • run_epanet_simulation(file_name) โ€” full hydraulic run + summary
  • modify_network(file_name, interventions) โ€” status / add_pipe / set_diameter / delete_pipe
  • plot_network(file_name) โ€” layout image
  • plot_pressures(file_name, low_threshold, only_critical)
  • plot_velocities(file_name, low_threshold, only_critical)
  • get_pressures_less_than(file_name, threshold)
  • get_pipes_over(file_name, threshold)

Bundled networks

{", ".join(f"{n}" for n in _NETWORKS) or "none"}


Wraps mcp-server-epanet by Dennis Zanutto (KWR), GPL-3.0 โ€” served unmodified over Streamable HTTP.

""" async def _respond(send, status, body: bytes, content_type="text/plain; charset=utf-8", extra=None): headers = [(b"content-type", content_type.encode())] if extra: headers += extra await send({"type": "http.response.start", "status": status, "headers": headers}) await send({"type": "http.response.body", "body": body}) def _origin_from_scope(scope) -> str: hdrs = dict(scope.get("headers") or []) host = hdrs.get(b"host", b"").decode() or f"localhost:{PORT}" proto = hdrs.get(b"x-forwarded-proto", b"").decode() or ( "http" if host.startswith(("localhost", "127.")) else "https" ) return f"{proto}://{host}" def build_app(): """Pure-ASGI wrapper. NOT BaseHTTPMiddleware (that breaks the streaming /mcp responses). Non-http scopes โ€” including `lifespan`, which starts the MCP session manager โ€” pass straight through to the inner FastMCP app.""" inner = mcp.streamable_http_app() # Starlette app; route /mcp; owns lifespan async def app(scope, receive, send): if scope["type"] != "http": await inner(scope, receive, send) return path = scope.get("path", "") or "/" if path in ("/", "/index.html"): html = LANDING_HTML.replace("{origin}", _origin_from_scope(scope)).encode("utf-8") await _respond(send, 200, html, "text/html; charset=utf-8") return if path in ("/health", "/healthz"): await _respond(send, 200, b'{"status":"ok"}', "application/json") return if path == "/mcp" or path.startswith("/mcp/"): if API_KEY: hdrs = dict(scope.get("headers") or []) if hdrs.get(b"authorization", b"").decode() != f"Bearer {API_KEY}": await _respond( send, 401, json.dumps({"error": "unauthorized", "detail": "Send Authorization: Bearer "}).encode(), "application/json", extra=[(b"www-authenticate", b'Bearer realm="mcp"')], ) return await inner(scope, receive, send) return await _respond(send, 404, b'{"error":"not found"}', "application/json") return app app = build_app() if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="info")