Spaces:
Sleeping
Sleeping
| """ | |
| 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://<user>-<space>.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 <key>` 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"""<!doctype html> | |
| <html lang="en"><head><meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>EPANET MCP Server</title> | |
| <style> | |
| :root {{ color-scheme: light dark; }} | |
| body {{ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; | |
| max-width: 46rem; margin: 3rem auto; padding: 0 1.25rem; line-height: 1.55; }} | |
| h1 {{ font-size: 1.5rem; margin-bottom: .25rem; }} | |
| .sub {{ opacity: .7; margin-top: 0; }} | |
| code, pre {{ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }} | |
| pre {{ background: rgba(127,127,127,.12); padding: .8rem 1rem; border-radius: .5rem; overflow:auto; }} | |
| .pill {{ display:inline-block; padding:.1rem .5rem; border-radius:1rem; | |
| background:rgba(56,132,255,.15); font-size:.8rem; }} | |
| ul {{ padding-left: 1.2rem; }} | |
| hr {{ border: none; border-top: 1px solid rgba(127,127,127,.25); margin: 1.75rem 0; }} | |
| .muted {{ opacity:.65; font-size:.85rem; }} | |
| </style></head> | |
| <body> | |
| <h1>🚰 EPANET MCP Server</h1> | |
| <p class="sub">KWR's conversational hydraulic modelling server, over the Model Context Protocol.</p> | |
| <p><span class="pill">Streamable HTTP</span> endpoint: <code>/mcp</code> | |
| · auth: <strong>{"Bearer token required" if API_KEY else "open (no key set)"}</strong></p> | |
| <hr> | |
| <h3>Connect a client</h3> | |
| <p>Point any Streamable-HTTP MCP client (e.g. HuggingChat) at:</p> | |
| <pre>{{origin}}/mcp</pre> | |
| <h3>Tools</h3> | |
| <ul> | |
| <li><code>run_epanet_simulation(file_name)</code> — full hydraulic run + summary</li> | |
| <li><code>modify_network(file_name, interventions)</code> — status / add_pipe / set_diameter / delete_pipe</li> | |
| <li><code>plot_network(file_name)</code> — layout image</li> | |
| <li><code>plot_pressures(file_name, low_threshold, only_critical)</code></li> | |
| <li><code>plot_velocities(file_name, low_threshold, only_critical)</code></li> | |
| <li><code>get_pressures_less_than(file_name, threshold)</code></li> | |
| <li><code>get_pipes_over(file_name, threshold)</code></li> | |
| </ul> | |
| <h3>Bundled networks</h3> | |
| <p>{", ".join(f"<code>{n}</code>" for n in _NETWORKS) or "none"}</p> | |
| <hr> | |
| <p class="muted">Wraps <a href="https://pypi.org/project/mcp-server-epanet/">mcp-server-epanet</a> | |
| by Dennis Zanutto (KWR), GPL-3.0 — served unmodified over Streamable HTTP.</p> | |
| </body></html>""" | |
| 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 <MCP_API_KEY>"}).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") |