mcp-hub
[CI]
[OpenSSF Scorecard]
[Socket Badge]
[Glama score]
[npm version]
[container image]
[docs]
[sponsor]
A dual-era Model Context Protocol (MCP) gateway: it
serves many stdio MCP servers from one container, published over HTTPS, and speaks
both MCP revisions on every endpoint — 2026-07-28 and 2025-11-25. The client
picks, and cannot tell which one it is on from the answers. On the 2026 revision that
includes elicitation — a child server's question reaches the person at the far end
instead of dying at the gateway
(how) — and subscriptions: the hub
serves subscriptions/listen to its clients and subscribes to its children on
whichever revision they speak, so a server that has never heard of it still reaches
a client that speaks nothing else
(how).
Lets MCP clients that cannot spawn a local process — ChatGPT connectors, Claude on
the Web and in Code, Mistral Le Chat, Cursor, LibreChat and any other
Streamable-HTTP client — reach every server behind it, with a built-in OAuth 2.1
login protected by a single password, plus long-lived API tokens for clients that
cannot do OAuth (OpenAI Responses API, xAI API, Gemini API). Per-client recipes:
client compatibility.
[MCP clients connect through a reverse proxy to mcp-hub: one Node process with an OAuth 2.1 authorization server, one path per server plus the /hub aggregate, and a supervisor keeping the stdio children and remote upstreams alive]
[Demo: config in, hub up, servers reachable through one endpoint]
Want to poke at it first? demo/ is a throwaway hub with three
fake servers — docker compose up -d, then point the
MCP Inspector or
MCPJam at it. Nothing to
configure, nothing to clean up but a volume.
Why
Wrapping each stdio MCP server in its own auth-proxy container costs a full
image, an OAuth stack, a hostname and a compose stack per server. mcp-hub
replaces N containers with one process:
- Config is exactly Claude Code's
mcpServers format — copy entries 1:1.
- Path-based routing:
https://host/paperless, https://host/homeassistant, …
/hub aggregate: register a single connector and reach every server
through 6 meta-tools (list_servers, list_tools, get_tool_schema,
call_tool, wake_server, sleep_server) without flooding the model
context with N×tools schemas.
- Per-server tool filtering:
allowTools / denyTools on any server decide
which of its tools the hub exposes — exact names or list_* prefixes. A
filtered tool is hidden from tools/list and refused if a client calls it
anyway, before the server is even woken, so a client holding a stale schema
cannot reach it.
- Also without HTTP:
mcp-hub --stdio serves that same aggregate on
stdin/stdout for clients that can only spawn a local process (Claude Desktop,
Codex, …) — same mcp.json, no TLS, no reverse proxy, no login. Auth exists
for the network endpoints; over stdio the trust boundary is the local user.
- On-demand lifecycle: stdio and docker servers start when used and sleep
after 60 idle minutes, answering
initialize/tools/list from a persistent
snapshot meanwhile — a dozen servers cost only the memory of the ones in
use. keepAlive: true exempts a server, IDLE_TIMEOUT_MINUTES=0 the hub.
- Servers that stay locked until you say so:
"unlock": true on a shell,
a mailbox or your DNS, and a model that calls it gets a link instead of a
result. You unlock it on the hub's page with your password (and code) for one
call or 15 minutes to four hours, for the connection that asked and nobody
else. Clients that open pages themselves get the link as a URL elicitation,
every other client as text, and a whole-locked server is put to sleep when
its last unlock ends.
- Optional two-factor login:
TWO_FACTOR=totp adds a code from any
authenticator app to the password on the OAuth login. Codes work once, wrong
ones pause further attempts, and switching it on retires every token that a
password alone had issued. mcp-hub-admin totp init draws the QR code in the
terminal.
- CIMD-first OAuth 2.1: clients identify themselves with a Client ID
Metadata Document — the
registration-free path the MCP spec now prefers — including
private_key_jwt
against the keys in their own document (metadata-document clients only). RFC
7591 dynamic registration stays advertised beside it for older clients,
mcp-hub-admin clients add issues credentials by hand for anything that can
do neither, and CLIENT_REGISTRATION turns either mechanism off.
- OAuth outwards, too: a remote server that speaks OAuth gets an
oauth
block instead of a static header. The hub registers itself — with credentials
the upstream issued, via RFC 7591, or with its own client metadata document —
then obtains and refreshes the token. client_credentials upstreams need no
attention at all; where a person must sign in, mcp-hub-admin upstream login
prints one URL. An upstream that needs re-authorizing shows up as one server
unauthorized, not as a confusing 401 in your client.
- Supervision: children are pinged and restarted with exponential backoff
when they die. A down server answers 503, not silence; a crash-looping
server nobody uses is parked instead of restarted forever.
- Hot reload: edits to
mcp.json start/stop/restart only the affected
servers.
- Stateless Streamable HTTP: no session state, so claude.ai's
reconnect-without-DELETE behaviour cannot leak processes or memory.
- Dual-era: every endpoint —
/hub, /<name>/mcp and --stdio — answers
MCP 2026-07-28 and 2025-11-25 alike; the client picks and cannot tell
from the answers which it got. On the 2026 revision that includes
elicitation: a server asking the user something returns the question
rather than pushing it, so it reaches the person at the far end instead of
dying at the gateway. The hub attributes it to the server that asked, strips
what could lie about that, drops embedded sampling and roots requests, and
seals the resumption state against the call it belongs to. passthrough: "off" withdraws one server's right to ask;
details.
- Change notifications, in both eras: a client opens a
subscriptions/listen stream and hears when a child's tools, prompts or
resources change. The hub subscribes to each child the way that child
understands — subscriptions/listen to a 2026 server, resources/subscribe
to a 2025 one — so the era gap is the gateway's problem rather than either
end's. The state is the open response, not a session table, so this costs the
stateless design nothing. A sleeping server watches nothing and is told to
re-read on waking; subscriptions: "off" withdraws one server's right to
push; details.
- Lightweight by design: one Node process, no database (state is one JSON
file plus a signing key under
/data), ten runtime dependencies, and
multi-arch images — a stated project goal is to run comfortably on a
single-board computer like a Raspberry Pi.
Servers to run behind it
The hub is server-agnostic — it serves any stdio MCP server whose entry fits
Claude Code's mcpServers format, which is most of them. These nineteen are
built and maintained alongside it, so their documentation carries the hub entry
you need and their tool filters line up with the hub's own allowTools /
denyTools:
Each one runs perfectly well on its own over stdio. Put them behind the hub when
you want them reachable from a client that cannot spawn a local process, or when
you would rather register one connector than nineteen.
Configuration
/config/mcp.json — identical to Claude Code (${VAR} expands from the
container environment; unknown fields are ignored by Claude Code, so the file
stays interchangeable). Install stdio server binaries at a reviewed, exact
version in your image; do not download mutable packages at runtime:
Stdio servers (command/args/env) are spawned as supervised child
processes. Remote servers (type: "http" or "sse" with url and optional
headers) are connected as MCP clients with the configured headers injected
on every request — the same supervision (ping, backoff reconnect, hot reload)
applies. An upstream that speaks OAuth gets an oauth block instead of a
header: the hub registers itself (statically, via RFC 7591 or via a client
metadata document), obtains the token and refreshes it, with one browser visit
started from the admin CLI where the grant needs a person.
"hub": false hides a server from the /hub aggregate; its own path keeps
working. "unlock" keeps a server's tools locked until a person unlocks them
(locked servers). allowTools / denyTools cut finer and apply to every kind of
server: a filtered tool is absent from both tools/list and /hub, and is
refused if called anyway — before the server is woken.
Reserved names: mcp, hub, authorize, token, register,
login, consent, health, livez, revoke, jwks, interaction,
session, userinfo, upstream, .well-known, unlock.
All stdio children share the hub's Unix user and can read its mounted files.
Only install fully trusted stdio servers. A server with a different trust level
belongs in its own container — and it does not have to speak HTTP to get there:
type: "docker" — the hub creates the container and talks stdio across the
container boundary over the Docker API. No HTTP listener, no bearer token,
no bridge process in the image. The hub itself never gets the Docker socket:
a separate mcp-hub-docker-proxy container holds it and allows only the
container operations mcp.json describes — nothing privileged, no host
mounts, no other images. Credentials can live with the proxy (secretsFrom)
so the hub process never holds them — and rotating one is just an edit: the
proxy watches the file and recreates the sandbox with the new values.
type: "unix" / "tcp" — you start the container, the hub connects to a
socket. Costs the hub no privileges at all, and a Unix socket reaches a
sandbox running with network_mode: none.
Both carry the newline-delimited JSON-RPC the specification asks custom
transports to reuse. See sandboxing
and SECURITY.md.
For a custom image, pin every package to an exact version:
Environment
Without a non-empty PASSWORD or a bcrypt PASSWORD_HASH the HTTP hub still
starts, but its login is disabled: the startup log says so, the sign-in page
answers 503 with the reason, and no client can be approved — so no token can
be issued. A configured hash takes precedence, and a hash that is not a bcrypt
hash disables the login the same way rather than falling back to PASSWORD.
The local --stdio mode uses neither variable.
The full table, including what applies in stdio mode, is in the
environment reference.
/data holds the Ed25519 JWT key, registered OAuth clients, approvals and
refresh tokens. Mount it as a volume — recreating it invalidates every
connector authorization.
Every access token is bound to one resource. The OAuth client includes the
resource advertised by the endpoint's RFC 9728 document — no client-side
configuration needed — and the resulting token is valid only there: a token for
/paperless/mcp cannot call /hub, /health or another server. The shorter
/<name> route is canonicalized to /<name>/mcp.
RESOURCE_BOUND_TOKENS=false turns this off and is a migration mode for
deployments from 0.4 and earlier, where tokens were issued without a resource
and reach every path. The hub logs a warning while it is set. Removing it
invalidates those unbound tokens, so every connector authorizes once more.
TRUSTED_PROXIES decides what req.ip is, and therefore what the login rate
limiter counts. List only your own reverse proxy, and make sure it
overwrites X-Forwarded-For rather than appending to it — otherwise a
client can supply its own address and rotate it to sidestep the per-IP limit.
If the variable is unset, every request appears to come from the proxy and
per-IP limiting degrades to a single global counter (the hub logs a warning
at startup). A global cap of 100 failures per 15 minutes applies either way.
Running
Option A — prebuilt image from GHCR (recommended)
Published on every push to main and every vX.Y.Z release tag, for
linux/amd64 and linux/arm64. Browse the versions on the
package page.
Tags: latest (tip of main), X.Y.Z and X.Y (releases), and
sha-<commit> for a specific build.
Use a version tag instead of latest for controlled updates. For an immutable
deployment, record the resolved digest from docker image inspect and use
ghcr.io/ni-c/mcp-hub:<version>@sha256:<digest> in Compose.
With compose, copy the example and point it at the image instead of building:
Or without compose:
Update to a newer image with docker compose pull && docker compose up -d
(or docker pull …, then recreate the container).
Option B — build from source
Option C — npm (without a container)
Installs as @ni-c/mcp-hub
(the unscoped npm name belongs to an unrelated project) and provides the
mcp-hub and mcp-hub-admin binaries. The container remains the recommended
deployment — it provides the isolation, read-only root filesystem and resource
limits that SECURITY.md assumes.
Reverse-proxy requirements: TLS termination, WebSockets/SSE allowed (proxy
buffering off, a request timeout above 310 seconds, a request-body limit at or
below MCP_BODY_LIMIT, and pass X-Forwarded-Proto/Host.
Connect a client: add https://<host>/hub (or https://<host>/<name>/mcp
for one server) as a custom connector — in ChatGPT (developer mode), Claude
Web, Mistral Le Chat, Cursor, LibreChat or any other OAuth-capable MCP client —
and log in once with the password. Claude Code:
claude mcp add -t http name https://<host>/<name>/mcp. API-only clients
(OpenAI Responses API, xAI, Gemini API) use an admin-minted token instead —
see client compatibility.
Each client is confirmed once per server it asks for. Entering the password
approves the client that asked, for the server shown on the page; while a login
session is still valid, a client you have not seen before — or one asking for
another server — gets an explicit Approve / Deny page instead of a code.
Approved clients reconnect silently from then on.
List clients or revoke one. The CLI shares /data with the running hub and
both sides re-read the state file before they touch it, so this works against a
live container — a revocation takes effect on the next request:
Revocation removes the approval and all refresh tokens and immediately rejects
already-issued access tokens. The next connection needs explicit approval.
delete goes further and removes the registration itself, and prune applies
the registration lifecycle rules
on demand — registrations that were never approved expire after a day, unused
ones after 90 days, and a dynamically registered client can also remove its own
registration through RFC 7592.
For clients that cannot do OAuth at all — the OpenAI Responses API, the xAI
API, Gemini's mcp_server tool, plain-header clients — the same CLI mints
long-lived, resource-bound API tokens:
The token is printed once and never stored; tokens revoke takes effect
immediately. Per-client recipes:
client compatibility.
Endpoints
Notes & limitations
- Change notifications (
listChanged, resource updates) are carried on
2026-07-28 via subscriptions/listen, whose state is the open response
rather than a session table. A 2025-11-25 client is offered neither, because
that revision needs a channel the stateless transport does not keep — so the
capability is withheld instead of announced and dropped. An on-demand server
watches nothing while it sleeps; the subscription is re-established on the
next wake and the client is told to re-read.
- Elicitation travels end to end on
2026-07-28: it is a result rather than a
push. Sampling and log messages are not forwarded.
- Access tokens are opaque and last 15 minutes. Revoking a client takes effect
on its next request rather than when the token expires — the token is a
reference to a stored record, so withdrawing it is a deletion. Refresh tokens
rotate; replaying one that was already rotated away is treated as a leak and
revokes the whole grant, access tokens included.
- Upstream auth is fully decoupled from the hub's own OAuth: an expired
upstream token just marks that one server
unauthorized (503 on its path, visible
in /health) — clients never see the upstream's 401.
- One login can approve multiple connectors, but each token is valid only for
its requested server or
/hub. Registration remains open as the MCP
specification intends; a client only receives codes after confirmation and
only at the confirmed redirect target.
- Failed logins are rate-limited (10/15 min per IP) and logged as
mcp-hub: authentication failure from <ip> for fail2ban.
- Auth pages deny framing and carry a restrictive CSP. MCP bodies are parsed
only after bearer verification and are bounded by size, per-client request
rate and per-client concurrency.
Logging to a file for fail2ban
LOG_FILE=/data/mcp-hub.log mirrors every log line into that file, one line
per entry with an ISO-8601 UTC prefix, while leaving the console output alone —
so docker logs keeps working. A jail then reads the file directly:
Only the hub's own lines are mirrored — the stdio children inherit stderr
directly, so their output stays in the container log and the file stays small.
Rotate it with logrotate (copytruncate, since the hub holds the file open).
Why not read the container's own logs instead: the Docker json-file path
contains the container ID and changes on every recreate, and the journald
driver maps all stderr to priority err — since an MCP server must keep
stdout free for the protocol and therefore logs to stderr, every ordinary line
would show up as a system error and drown out host monitoring.
Bans belong in the DOCKER-USER chain (banaction = iptables-allports) when
the hub is published through a container-based reverse proxy: that traffic
arrives via DNAT and FORWARD, and never passes INPUT.
Development