Skip to content

> STATUS: SUPERSEDED 2026-05-17 — current implementation lives in services/mcp-gateway.md. Kept for design-rationale history.

# MCP Gateway — Reconstructed Design Spec (in-progress, "Phase 1")

> Reconstructed 2026-05-16 from code/configs/git history. The gateway is a single-squash-commit first draft (`0cad389 "Phase 1: Create MCP Gateway with Docker-in-Docker support"`, preceded by `726bd10 "WIP: MCP gateway prep"`). Nothing has a second iteration in git — everything below is first-draft intent.

## 1. Goal / Intent

A single OAuth-protected HTTP entrypoint at `https://mcp.nuclide.systems` that exposes a curated set of MCP servers to AI clients on the homelab. Primary consumer: Claude.ai as a remote connector (SSE at `/`, every README's "Usage in Claude.ai"). Secondary: LobeChat (`chat.nuclide.systems`) and LiteLLM (`ai.nuclide.systems`), sharing the same Pocket ID OAuth app. It is meant to replace the "cumbersome" static-compose approach (`mcp-tools.yaml`) with a dynamic, UI-managed, self-hosting model — answering the open `todo.md` question "MCP deployment seems cumbersome — can litellm host directly? how to integrate npx, uvx, docker-based containers?". Unifying idea: normalize npx / uvx / docker MCP servers behind one Dockerized gateway.

## 2. Architecture (three competing models in-repo; A chosen, B orphaned, C aspirational)

A. FastAPI gateway + Docker-in-Docker (chosen) — `ai/mcp-gateway/` - FastAPI + `uvicorn` on `0.0.0.0:8080`, container `mcp-gateway`. - DinD via bind-mounted `/var/run/docker.sock`; `docker.from_env()`. - Per-server containers spawned `mcp-<name>`, hardcoded onto `ai-internal`. - Config `config.json` (RW bind, currently EMPTY → falls back to `DEFAULT_SERVERS`). - Gateway joins `ai-internal` + `shared_backend` (both `external: true`).

B. Static compose `mcp-tools.yaml` — orphaned; `ai/docker-compose.yml:6` include is commented out. Internally malformed (see §4).

C. LiteLLM-hosted — `litellm-config/config.yaml` `mcp_servers: {}` empty. Confirms MCP hosting was intended for the gateway, not LiteLLM (the `todo.md` "can litellm host directly?" question remains open).

Transports (normalized to HTTP-on-:8000): streamable-http (nextcloud, mermaid), `mcp-proxy --stateless` stdio→HTTP (papersearch), native HTTP (markitdown, crawl4ai :11235), and the gateway's own SSE `/` endpoint — a STUB (fake `initialize` + 60s pings, no routing to backends).

Reverse proxy: Zoraxy `mcp.nuclide.systems → 192.168.1.40:8080`. `mcp-auth.nuclide.systems` is an abandoned auth-sidecar idea (not exposed).

OAuth (Pocket ID @ `id.nuclide.systems`): `OAuth2AuthorizationCodeBearer`, scopes `{openid, mcp}`, token validation via userinfo. Shared gateway client (`GENERIC_CLIENT_ID`, same as LiteLLM/LobeChat). Per-server OAuth for papersearch & nextcloud against the same Pocket ID.

## 3. MCP Server Inventory (reconciled — `server.py` MCP_SERVERS is authoritative)

Server | Image / build | Transport | Port | Auth | Status
--- | --- | --- | --- | --- | ---
papersearch | `python:3.12-slim` + runtime `uv tool install mcp-proxy` → `paper_search_mcp.server` | mcp-proxy stdio→http | 8000 | Pocket ID `PAPERSEARCH_MCP_OAUTH_*` | plausible, runtime-install fragile
nextcloud | `ghcr.io/cbcoutinho/nextcloud-mcp-server:latest` | streamable-http | 8000 | Pocket ID `NEXTCLOUD_MCP_OAUTH_*` | likely workable (real image)
markitdown | `python:3.12-slim` + `uvx markitdown-mcp --http` | http | 8000 | none | broken as written (`uvx` not in base image)
comfyui | `ghcr.io/richardi-ai/comfyui-mcp-server:latest` (`type:"npm"` mismatch) | unspecified | 8000 | none | image not pullable; backend ComfyUI was crash-looping
crawl4ai | `unclecode/crawl4ai:latest` | http | 11235 | none | likely workable; resource limits lost in rewrite
mermaid | `node:20-slim` + runtime `npx -y mcp-mermaid` | streamable-http | 8000 | none | plausible, slow first start

## 4. Implemented vs Unfinished vs Broken

Implemented: FastAPI app + OAuth scheme + userinfo token validation; container lifecycle CRUD + persistence; Web UI SPA (`templates/ui.html` @ `/ui`); gateway compose/Dockerfile + Zoraxy route.

Unfinished / stub: - SSE `/` is fake — no MCP transport bridging Claude.ai → spawned servers. Core gap. - No routing to per-server containers; all five servers bind the same `:8000` and `spawn_container` host-publishes `8000:8000` → two servers can't run at once. - OAuth callback non-functional — token-exchange URL built via `OAUTH_REDIRECT_URI.replace("/sso/callback","/token")` (→ wrong host, not the Pocket ID token endpoint); token never stored/used. - `config.json` empty → always defaults; secrets hardcoded plaintext in `server.py`.

Broken / contradictory: - `ai/docker-compose.yml:6` mcp-tools include commented out; gateway compose is a separate project not referenced by the stack either — wired in only via Zoraxy. - `mcp-tools.yaml`: duplicate `markitdown-mcp` key; `comfyui-mcp` env missing `=` (`- COMFYUI_URL http://comfyui:8188`); missing images/ports. - `comfyui` server `type:"npm"` vs Docker-image mismatch; upstream image/npm package existence unverified (image confirmed not pullable). - `.env` has `CRAWL4AI_MCP_OAUTH_*`, `COMFYUI_MCP_OAUTH_*` that `server.py` never consumes; code hardcodes secrets instead of `${ENV}` substitution.

## 5. Relevant `.env` keys (names only)

Gateway: `GENERIC_CLIENT_ID/_SECRET/_REDIRECT_URI`, `GENERIC_{AUTHORIZATION,TOKEN,USERINFO}_ENDPOINT`, `GENERIC_CLIENT_USE_PKCE`, `OAUTH_SCOPES`, `OAUTH_TOKEN_URL`. Per-server: `NEXTCLOUD_MCP_OAUTH_CLIENT_ID/_SECRET`, `PAPERSEARCH_MCP_OAUTH_CLIENT_ID/_SECRET`, `MARKITDOWN_MCP_OAUTH_CLIENT_ID/_SECRET` (declared, unused), `CRAWL4AI_/COMFYUI_MCP_OAUTH_*` (orphaned). papersearch data sources: `UNPAYWALL_EMAIL`, `CORE_API_KEY`, `SEMANTIC_SCHOLAR_API_KEY`, `ZENODO_ACCESS_TOKEN`, `GOOGLE_SCHOLAR_PROXY_URL`, `DOAJ_API_KEY`. comfyui: `COMFYUI_URL`, `COMFYUI_WS_URL`.

## 6. Open Design Decisions (must resolve)

1. Hosting model: DinD gateway vs static `mcp-tools.yaml` vs LiteLLM-hosted.
2. How Claude.ai/LobeChat reach a tool: the MCP transport bridge doesn't exist.
3. Port allocation: all servers hardcode `:8000` — need internal DNS, no host publish.
4. Uniform npx/uvx/docker run model: prebuilt images vs runtime install.
5. Auth model: gateway-terminated vs per-MCP vs pass-through (callback is broken).
6. Secret handling: hardcoded → `${ENV}` from `ai/.env`.
7. comfyui server: image vs npm; keep only once ComfyUI itself is stable.
8. Discovery for LobeChat/LiteLLM: `/mcp.json` is OAuth-gated, lists config not endpoints.

## 7. Recommended Path (ordered, lowest-risk first)

1. Pick the static-compose path, not DinD — lowest risk on a single NUC; DinD adds socket-exposure risk + a broken SSE bridge for little gain. Fix and re-enable `mcp-tools.yaml` (uncomment `ai/docker-compose.yml:6`).
2. Fix `mcp-tools.yaml`: dedupe `markitdown-mcp`, fix `comfyui-mcp` env `=`, unique service names → `ai-internal` DNS, pin images, drop comfyui for now.
3. One streamable-http reverse proxy keyed by path (`mcp.nuclide.systems/<server>`) via Zoraxy or a small `httpx` proxy — replace the fake SSE stub. Backends stay internal on `ai-internal:8000`, never host-published.
4. Move secrets to `${ENV}` from `ai/.env` (keys already exist).
5. Fix OAuth callback: exchange code against `GENERIC_TOKEN_ENDPOINT`.
6. Verify each upstream image/tool exists before marking a server "working".
7. Defer the DinD gateway + Web UI to phase-2 (read-only status over the running compose, not spawning).
8. Answer the litellm question: once stable `https://mcp.nuclide.systems/<server>` URLs exist, populate `litellm-config/config.yaml` `mcp_servers:` so LiteLLM/LobeChat discover them — no bespoke discovery path needed.