Architecture
What actually happens between an HTTP request arriving and a tool result going back out.
One process, four parts
The Express app sets trust proxy from TRUSTED_PROXIES, mounts /livez unauthenticated, mounts the auth router, and then registers two routes per configured server plus /hub.
The OAuth authorization server is the MCP SDK's mcpAuthRouter with a custom provider: password login, per-client approval, EdDSA JWTs, rotating refresh tokens, all persisted to one JSON file.
The supervisor owns one long-lived MCP client per configured server — a child process for stdio entries, an HTTP/SSE client for remote ones — and keeps it alive.
The proxy layer builds a throwaway MCP Server per HTTP request that forwards requests verbatim to the supervisor's client.
Request pipeline
The order of the middleware is deliberate:
- Rate limit — before anything is parsed, and before an unknown IP is inserted into any table.
- Bearer verification — an EdDSA JWT with a pinned algorithm.
- Resource check — the token's audience must match this endpoint;
/healthshares the/hubresource. - Per-client gate — requests per minute and in-flight concurrency, keyed by OAuth client rather than IP.
- Body parsing — capped at
MCP_BODY_LIMIT, and only now, so an unauthenticated request never allocates a megabyte. - Routing — to
/hub, to one server's proxy, or 404.
An unauthenticated request costs a JWT verification and nothing more: no disk access, no bcrypt, no allocation proportional to the body.
Stateless transport
Each MCP request gets a fresh Server and a StreamableHTTPServerTransport with sessionIdGenerator: undefined — no session ID, no server-side session table. When the HTTP response closes, both are closed and forgotten.
The reason is concrete: claude.ai reconnects roughly every five minutes and does not send a session DELETE first. Any per-session state would accumulate one entry per reconnect, forever, and take processes or memory with it. Statelessness makes that impossible by construction.
The cost is that server-initiated messages have nowhere to go. listChanged notifications, resource subscriptions and sampling are not delivered to clients. Request/response traffic — tools, resources, prompts, completions — is forwarded in full, and the proxy advertises only the capabilities its child actually declared.
Supervisor lifecycle
The numbers, all fixed:
| Ping interval | 60 s |
| Ping timeout | 30 s |
| Initial backoff | 1 s |
| Maximum backoff | 5 min |
| Backoff reset | after 5 min of uptime |
A ping failure is treated as death: the client is closed, which triggers the same restart path an exit would. There is no separate "unhealthy but running" state to reason about.
While a server is not up, its path answers a JSON-RPC error with HTTP 503 naming the state. A client gets a clear failure instead of a hanging request.
Configuration hot reload
The config file is watched two ways: fs.watch on the parent directory, and fs.watchFile polling the file itself every 3 seconds. Both funnel into a 300 ms debounce.
The poller is not belt-and-braces. With a single-file bind mount — -v ./mcp.json:/config/mcp.json — an edit on the host produces no inotify event inside the container: the container's /config directory never changes, and the mount is a bind of one inode. Without polling, host-side edits would never be seen.
On a change the new file is parsed and diffed against the running configuration. Added servers start, removed servers stop, changed servers restart, untouched servers keep their connections. A file that fails to parse is logged and ignored — the previous configuration stays live.
The /hub aggregate
Registering nine connectors puts nine servers' worth of tool schemas into the model's context before a question is asked. /hub inverts that: one connector, four meta-tools, and schemas fetched only when needed.
The hub keeps a per-server tool cache, refreshed when a child sends tools/list_changed, so list_tools answers without a round trip to the child. call_tool forwards with a five-minute timeout that resets on progress notifications.
Servers marked "hub": false are invisible here: list_servers omits them and call_tool refuses them. Their own paths are unaffected.
See the meta-tool reference for the exact schemas.
State on disk
/data holds everything that must survive a restart:
| File | Contents |
|---|---|
jwt-key.pem | the Ed25519 signing key, generated on first boot |
state.json | registered OAuth clients, approvals, refresh-token families, revocation markers |
mcp-hub.log | only if LOG_FILE points there |
There is no database and no migration step. A corrupt state.json is moved aside as state.json.corrupt-<timestamp> and the hub boots with empty state rather than crash-looping — connectors then have to authorize again, which is recoverable, unlike a hub that will not start.
Losing /data invalidates every connector authorization. Treat both files as secrets: anyone holding jwt-key.pem can mint access tokens.