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 &lt;key&gt;</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 &lt;YOUR_KEY&gt;" }}
    }}
  }}
}}</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")