KWR_EPANET_MCP / app.py
razaali10's picture
Update app.py
bda8203 verified
Raw
History Blame Contribute Delete
7.42 kB
"""
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> &nbsp; endpoint: <code>/mcp</code>
&nbsp;·&nbsp; 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")