| """SWMM Analysis MCP Server — dual-surface deployment for Hugging Face Spaces. |
| |
| Surfaces |
| -------- |
| 1. MCP (Streamable HTTP) at /mcp |
| For MCP-native clients: Claude Desktop & claude.ai custom connectors, |
| Gemini CLI/clients, ChatGPT connectors, LangChain MCP adapters, n8n MCP |
| Client node, Flowise/Langflow MCP tools, Codex CLI. Stateless HTTP + |
| JSON responses for maximum client compatibility; DNS-rebinding |
| protection disabled (required behind the HF Spaces proxy). |
| |
| 2. REST/OpenAPI at /api/* (schema at /openapi.json) |
| Every registry tool as POST /api/tool/{name}; used by Custom GPT |
| Actions, n8n HTTP Request nodes, plain webhooks, and anything that |
| speaks OpenAPI rather than MCP. |
| |
| 3. Built-in agent at POST /api/agent (also MCP tool `agent_analyze`) |
| A multi-provider LLM tool-loop over the same registry, for callers that |
| want one natural-language endpoint (see agent.py). |
| |
| 4. GET /files/{session_id}/{filename} — generated report downloads. |
| |
| Both surfaces dispatch to the same registry in tools.py, so behaviour is |
| identical on every platform. |
| """ |
| from __future__ import annotations |
|
|
| import contextlib |
| import inspect |
| import json |
| import os |
| from pathlib import Path |
| from typing import Any |
|
|
| from fastapi import Body, FastAPI, HTTPException |
| from fastapi.responses import FileResponse, HTMLResponse, JSONResponse |
|
|
| from mcp.server.fastmcp import FastMCP |
| from mcp.server.transport_security import TransportSecuritySettings |
|
|
| import agent as agent_module |
| from sessions import SESSION_ROOT, STORE |
| from tools import TOOL_REGISTRY |
|
|
| SERVER_NAME = "swmm-analysis" |
| SERVER_VERSION = "1.0.4 (engine Rev 23.2; generic CoC workflow Rev 28)" |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| mcp = FastMCP( |
| SERVER_NAME, |
| instructions=( |
| "Deterministic EPA-SWMM stormwater analysis: upload a .inp model, run the " |
| "crash-isolated simulation, then query results, Calgary-style screening, " |
| "QA/QC findings, engine-report reconciliation, controlled scenarios, and " |
| "SWMR draft reports. Typical flow: upload_model -> run_simulation -> " |
| "analysis tools with the returned session_id. All outputs are preliminary " |
| "engineering screening, not professional determinations." |
| ), |
| stateless_http=True, |
| json_response=True, |
| transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False), |
| ) |
|
|
| for _name, _fn in TOOL_REGISTRY.items(): |
| mcp.tool()(_fn) |
|
|
|
|
| @mcp.tool() |
| def agent_analyze(question: str, provider: str = "anthropic", model: str = "", |
| session_id: str = "", api_key: str = "", base_url: str = "") -> dict: |
| """Ask the built-in agent a natural-language question; it plans and runs the |
| SWMM tools itself and returns an answer plus a full tool audit trail. |
| Providers: anthropic, openai, gemini, groq, mistral, local (keys via Space |
| secrets or api_key argument). MCP clients normally drive tools directly — |
| use this when you want server-side orchestration.""" |
| return agent_module.run_agent( |
| question=question, provider=provider, model=model or None, |
| api_key=api_key or None, base_url=base_url or None, |
| session_id=session_id or None) |
|
|
|
|
| mcp_app = mcp.streamable_http_app() |
|
|
| |
| |
| |
|
|
| @contextlib.asynccontextmanager |
| async def lifespan(app: FastAPI): |
| async with contextlib.AsyncExitStack() as stack: |
| await stack.enter_async_context(mcp.session_manager.run()) |
| yield |
|
|
|
|
| app = FastAPI( |
| title="SWMM Analysis MCP Server", |
| version=SERVER_VERSION, |
| description="Dual-surface (MCP + REST) EPA-SWMM analysis server with a built-in multi-provider agent. " |
| "MCP endpoint: /mcp. Engine: OpenSWMM in a crash-isolated worker, Calgary screening, " |
| "deterministic QA/QC, .rpt reconciliation, scenarios, SWMR reporting.", |
| lifespan=lifespan, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def _tool_meta(name: str, fn) -> dict: |
| sig = inspect.signature(fn) |
| return { |
| "name": name, |
| "description": (fn.__doc__ or "").strip(), |
| "parameters": { |
| p: {"required": prm.default is inspect.Parameter.empty, |
| "default": None if prm.default is inspect.Parameter.empty else prm.default} |
| for p, prm in sig.parameters.items() |
| }, |
| "rest": f"POST /api/tool/{name}", |
| } |
|
|
|
|
| @app.get("/api/tools") |
| def rest_list_tools() -> dict: |
| """List all tools with parameter metadata (machine-readable).""" |
| return {"server": SERVER_NAME, "version": SERVER_VERSION, |
| "tools": [_tool_meta(n, f) for n, f in TOOL_REGISTRY.items()]} |
|
|
|
|
| @app.post("/api/tool/{tool_name}") |
| def rest_call_tool(tool_name: str, payload: dict = Body(default={})) -> JSONResponse: |
| """Invoke any registry tool. Body = the tool's keyword arguments as JSON.""" |
| fn = TOOL_REGISTRY.get(tool_name) |
| if fn is None: |
| raise HTTPException(404, f"Unknown tool '{tool_name}'. See /api/tools.") |
| try: |
| result = fn(**(payload or {})) |
| except (KeyError, ValueError, TypeError) as exc: |
| raise HTTPException(400, str(exc)) |
| except Exception as exc: |
| raise HTTPException(500, f"{type(exc).__name__}: {exc}") |
| return JSONResponse(json.loads(json.dumps(result, default=str))) |
|
|
|
|
| @app.post("/api/agent") |
| def rest_agent(payload: dict = Body(...)) -> JSONResponse: |
| """Built-in agent endpoint. |
| |
| Body: {"question": str, "provider": "anthropic|openai|gemini|groq|mistral|local", |
| "model": str?, "api_key": str?, "base_url": str?, "session_id": str?, |
| "inp_content": str?, "allow_report": bool?} |
| """ |
| question = payload.get("question", "").strip() |
| if not question: |
| raise HTTPException(400, "'question' is required.") |
| try: |
| result = agent_module.run_agent( |
| question=question, |
| provider=payload.get("provider", "anthropic"), |
| model=payload.get("model") or None, |
| api_key=payload.get("api_key") or None, |
| base_url=payload.get("base_url") or None, |
| session_id=payload.get("session_id") or None, |
| inp_content=payload.get("inp_content") or None, |
| allow_report=bool(payload.get("allow_report", False)), |
| ) |
| except ValueError as exc: |
| raise HTTPException(400, str(exc)) |
| except Exception as exc: |
| raise HTTPException(502, f"Agent/provider error: {type(exc).__name__}: {exc}") |
| return JSONResponse(json.loads(json.dumps(result, default=str))) |
|
|
|
|
| @app.get("/files/{session_id}/{filename}") |
| def serve_file(session_id: str, filename: str) -> FileResponse: |
| """Download generated report artifacts.""" |
| safe_session = Path(session_id).name |
| safe_file = Path(filename).name |
| path = (SESSION_ROOT / safe_session / "outputs" / safe_file).resolve() |
| if not str(path).startswith(str(SESSION_ROOT.resolve())) or not path.exists(): |
| raise HTTPException(404, "File not found (sessions expire; regenerate the report).") |
| return FileResponse(path, filename=safe_file) |
|
|
|
|
| @app.get("/health") |
| def health() -> dict: |
| return {"status": "ok", "server": SERVER_NAME, "version": SERVER_VERSION, |
| "tools": len(TOOL_REGISTRY) + 1, "sessions": len(STORE.list()), |
| "mcp_endpoint": "/mcp", "openapi": "/openapi.json"} |
|
|
|
|
| @app.get("/", response_class=HTMLResponse) |
| def index() -> str: |
| tool_rows = "".join( |
| f"<tr><td><code>{n}</code></td><td>{(f.__doc__ or '').strip().splitlines()[0]}</td></tr>" |
| for n, f in TOOL_REGISTRY.items()) |
| return f"""<!doctype html><html><head><title>SWMM Analysis MCP Server</title> |
| <style>body{{font-family:system-ui;max-width:900px;margin:2rem auto;padding:0 1rem;color:#222}} |
| code{{background:#f2f2f2;padding:1px 5px;border-radius:4px}}table{{border-collapse:collapse;width:100%}} |
| td,th{{border:1px solid #ddd;padding:6px 10px;text-align:left;font-size:14px}}h1{{color:#0a4d8c}}</style></head> |
| <body><h1>🌧️ SWMM Analysis MCP Server</h1> |
| <p>Deterministic EPA-SWMM stormwater analysis (engine Rev 23.2) with dual MCP + REST surfaces |
| and a built-in multi-provider agent. All results are preliminary engineering screening.</p> |
| <ul> |
| <li><b>MCP endpoint (Streamable HTTP):</b> <code><this-space-url>/mcp</code></li> |
| <li><b>REST:</b> <code>POST /api/tool/{{name}}</code> — catalog at <a href="/api/tools">/api/tools</a>, |
| schema at <a href="/openapi.json">/openapi.json</a></li> |
| <li><b>Agent:</b> <code>POST /api/agent</code> (anthropic · openai · gemini · groq · mistral · local)</li> |
| <li><b>Health:</b> <a href="/health">/health</a></li> |
| </ul> |
| <h3>Tools ({len(TOOL_REGISTRY) + 1})</h3><table><tr><th>Tool</th><th>Purpose</th></tr>{tool_rows} |
| <tr><td><code>agent_analyze</code></td><td>Server-side agent loop over all tools (MCP + REST).</td></tr></table> |
| <p>Typical flow: <code>upload_model</code> → <code>run_simulation</code> → analysis tools with the returned |
| <code>session_id</code>. See the README for per-platform connection instructions.</p></body></html>""" |
|
|
|
|
| |
| app.mount("/", mcp_app) |
|
|
| if __name__ == "__main__": |
| import uvicorn |
| uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "7860"))) |
|
|