diff --git a/README.md b/README.md index 3db12f8..aedcda6 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,14 @@ Then **connect the app to the network that can reach `timescale_db`** (the track stack's network) so the `timescale_db` hostname resolves. Coolify manages the Traefik labels + TLS from the domain you set. +Optional env (sensible defaults): +- `MCP_MAX_ROWS` (default `10000`) — hard ceiling on rows a query may return. +- `MCP_DNS_REBINDING_PROTECTION` (default `0`/off) — the MCP SDK's localhost Host-header + guard. It returns `421` behind a reverse proxy, so it's off by default (the service is + TLS-terminated + Bearer-authed). Set `1` to enforce, with `MCP_ALLOWED_HOSTS`. +- `MCP_ALLOWED_HOSTS` — comma-separated allowlist used only when the guard is on + (default: the two `fleetmcp.*` domains + localhost). + **1b. Manual host deploy (fallback):** check this repo out on the host and run `deploy.sh` — it builds the image, derives the read-only `DATABASE_URL` from the running stack, and runs a standalone Traefik bridge. See the script header. diff --git a/analytics_mcp.py b/analytics_mcp.py index b6b74d4..0b8170e 100644 --- a/analytics_mcp.py +++ b/analytics_mcp.py @@ -36,6 +36,7 @@ import psycopg2 import psycopg2.extras import psycopg2.pool from mcp.server.fastmcp import FastMCP +from mcp.server.transport_security import TransportSecuritySettings from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse @@ -143,7 +144,27 @@ def _guard(sql: str) -> str: # ── MCP server + tools ─────────────────────────────────────────────────────── -mcp = FastMCP("fireside-analytics", stateless_http=True) +# The MCP SDK ships DNS-rebinding protection that, by default, only accepts a +# localhost Host header and returns 421 for anything else — which breaks this +# service behind Traefik (Host = fleetmcp.*). That protection targets browser +# attacks on localhost-bound servers; it does not apply to a public, TLS-terminated, +# Bearer-authenticated service. So it is OFF by default here, and re-enableable via +# MCP_DNS_REBINDING_PROTECTION=1 with an explicit MCP_ALLOWED_HOSTS allowlist. +_DNS_PROT = os.getenv("MCP_DNS_REBINDING_PROTECTION", "0") == "1" +_ALLOWED_HOSTS = [ + h.strip() + for h in os.getenv( + "MCP_ALLOWED_HOSTS", + "fleetmcp.fivetitude.com,fleetmcp.rahamafresh.com,localhost,127.0.0.1", + ).split(",") + if h.strip() +] +_transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=_DNS_PROT, + allowed_hosts=_ALLOWED_HOSTS, + allowed_origins=[f"https://{h}" for h in _ALLOWED_HOSTS], +) +mcp = FastMCP("fireside-analytics", stateless_http=True, transport_security=_transport_security) @mcp.tool() diff --git a/deploy.sh b/deploy.sh index 9047818..db75239 100755 --- a/deploy.sh +++ b/deploy.sh @@ -74,4 +74,4 @@ rm -f "$ENV_FILE" sleep 5 echo "== container =="; docker ps --filter name="$NAME" --format "{{.Names}} | {{.Status}}" echo "== DB role (expect analytics_ro) =="; docker exec "$NAME" sh -lc 'printenv DATABASE_URL | sed -E "s#://([^:]+):[^@]+@#://\1:@#"' -echo "== health =="; docker exec "$NAME" sh -lc "curl -s http://localhost:${PORT}/healthz" 2>&1 | head +echo "== health =="; docker exec "$NAME" python -c "import urllib.request; print(urllib.request.urlopen('http://localhost:${PORT}/healthz').read().decode())" 2>&1 | head