Skip to content

Architecture

What actually happens between an HTTP request arriving and a tool result going back out.

One process, four parts

Internal structure of the mcp-hub processExpress apptrust proxyrequest pipelineearly rate limitbearer verifyresource checkper-client gateparse body ≤ 1 MBrouteOAuth 2.1 ASDCR · PKCE · consent/hub server4 meta-toolsproxy serverbuilt per requestsupervisorstdio childstdio childremote upstream
The supervisor owns the connections; the OAuth server, the hub server and the per-request proxies all borrow them.

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:

  1. Rate limit — before anything is parsed, and before an unknown IP is inserted into any table.
  2. Bearer verification — an EdDSA JWT with a pinned algorithm.
  3. Resource check — the token's audience must match this endpoint; /health shares the /hub resource.
  4. Per-client gate — requests per minute and in-flight concurrency, keyed by OAuth client rather than IP.
  5. Body parsing — capped at MCP_BODY_LIMIT, and only now, so an unauthenticated request never allocates a megabyte.
  6. 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

Supervisor state machine: starting, up, down, backoff, restartstartingpath answers 503upping every 60 sdownexit or ping timeoutbackoff1 s → 5 minconnectedrestart after the delay, doubling each time5 minutes of uptime resets the delay to 1 s
The backoff never gives up — a server whose dependency is down recovers on its own once the dependency returns.

The numbers, all fixed:

Ping interval60 s
Ping timeout30 s
Initial backoff1 s
Maximum backoff5 min
Backoff resetafter 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.

Context cost of N connectors versus the hub aggregateN direct connectorsserver A — every tool schemaserver B — every tool schemaserver C — every tool schemaN × tools, loaded up frontOne /hub connectorlist_serverslist_toolsget_tool_schemacall_tool4 schemas; the rest fetched on demand
The trade is one extra round trip before an unfamiliar tool call, in exchange for a context that does not scale with the number of servers.

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:

FileContents
jwt-key.pemthe Ed25519 signing key, generated on first boot
state.jsonregistered OAuth clients, approvals, refresh-token families, revocation markers
mcp-hub.logonly 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.

Released under the MIT License. Not affiliated with Anthropic; “Claude” is a trademark of Anthropic PBC.