Back to Browse

Hub MCP Server

by Ni C
Developer ToolsLow Risk9.7MCP RegistryLocal
Free

Server data from the Official MCP Registry

Serve many stdio MCP servers from one container over HTTPS, with a built-in OAuth 2.1 login

About

Serve many stdio MCP servers from one container over HTTPS, with a built-in OAuth 2.1 login

Security Report

9.7
Low Risk9.7Low Risk

Valid MCP server (4 strong, 2 medium validity signals). 1 code issue detected. 1 known CVE in dependencies Package registry verified. Imported from the Official MCP Registry. 1 finding(s) downgraded by scanner intelligence.

5 files analyzed Β· 3 issues found

Security scores are indicators to help you make informed decisions, not guarantees. Always review permissions before connecting any MCP server.

Permissions Required

This plugin requests these system permissions. Most are normal for its category.

file_system

Check that this permission is expected for this type of plugin.

HTTP Network Access

Connects to external APIs or services over the internet.

env_vars

Check that this permission is expected for this type of plugin.

What You'll Need

Set these up before or after installing:

Public base URL exactly as clients see it, e.g. https://mcp.example.net (no path)Optional

Environment variable: EXTERNAL_URL

bcrypt hash of the OAuth login password (htpasswd -bnBC 10 "" 'pw' | tr -d ':\n')Required

Environment variable: PASSWORD_HASH

Path to the mcp.json listing the servers to expose, in Claude Code's mcpServers format (default /config/mcp.json)Optional

Environment variable: CONFIG_PATH

Directory for the JWT key and OAuth client state; must persist across restarts (default /data)Optional

Environment variable: DATA_PATH

Comma-separated IPs/CIDRs of the reverse proxy allowed to set X-Forwarded-* headersOptional

Environment variable: TRUSTED_PROXIES

Listen portOptional

Environment variable: PORT

How to Install

Add this to your MCP configuration file:

{
  "mcpServers": {
    "io-github-ni-c-mcp-hub": {
      "env": {
        "PORT": "your-port-here",
        "DATA_PATH": "your-data-path-here",
        "CONFIG_PATH": "your-config-path-here",
        "EXTERNAL_URL": "your-external-url-here",
        "PASSWORD_HASH": "your-password-hash-here",
        "TRUSTED_PROXIES": "your-trusted-proxies-here"
      },
      "args": [
        "-y",
        "@ni-c/mcp-hub"
      ],
      "command": "npx"
    }
  }
}

Documentation

View on GitHub

From the project's GitHub README.

mcp-hub

CI npm version npm downloads Container License: MIT Docs

πŸ“– Full documentation: https://mcp-hub.ni-c.de

Serve many stdio MCP servers from one container, published over HTTPS for Claude Web custom connectors, Claude Code and any other Streamable-HTTP MCP client β€” with a built-in OAuth 2.1 login protected by a single password.

Claude ──TLS──> reverse proxy ──> mcp-hub (one Node process)
                                   β”œβ”€ OAuth 2.1 AS (DCR, PKCE, password login)
                                   β”œβ”€ /<name>, /<name>/mcp   one path per server
                                   β”œβ”€ /hub                   4 meta-tools for all servers
                                   └─ supervisor: one stdio child per server, auto-restart

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 4 meta-tools (list_servers, list_tools, get_tool_schema, call_tool) without flooding the model context with NΓ—tools schemas.
  • Supervision: children are spawned at boot, pinged, and restarted with exponential backoff when they die. A down server answers 503, not silence.
  • 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.
  • No database: state is one JSON file plus a JWT key under /data.

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:

{
  "mcpServers": {
    "paperless": {
      "command": "paperless-mcp",
      "args": [],
      "env": { "PAPERLESS_API_TOKEN": "${PAPERLESS_API_TOKEN}" }
    },
    "homeassistant": {
      "type": "http",
      "url": "http://homeassistant:8123/api/mcp",
      "headers": { "Authorization": "Bearer ${HA_TOKEN}" }
    },
    "private-thing": { "command": "some-mcp", "args": [], "hub": false }
  }
}

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. Upstreams that require their own interactive OAuth cannot be configured with static headers; bridge those with an mcp-remote stdio entry and persist its token cache (MCP_REMOTE_CONFIG_DIR) under /data. "hub": false hides a server from the /hub aggregate; its own path keeps working. Reserved names: mcp, hub, authorize, token, register, login, consent, health, livez, revoke.

All stdio children share the hub's Unix user and can read its mounted files. Only install fully trusted stdio servers. Put servers with a different trust level in separate containers/hosts and connect them as remote HTTP/SSE servers; see SECURITY.md.

For a custom image, pin every package to an exact version:

FROM ghcr.io/ni-c/mcp-hub:0.5.0
USER root
RUN npm install -g your-mcp-package@1.2.3
USER node

Environment

VariableRequiredDescription
EXTERNAL_URLyesPublic base URL, e.g. https://mcp.example.net (no path)
PASSWORD_HASHone ofbcrypt hash of the login password (htpasswd -bnBC 10 "" 'pw' | tr -d ':\n')
PASSWORDone ofplain-text alternative to PASSWORD_HASH
TRUSTED_PROXIESnocomma-separated IPs/CIDRs allowed to set X-Forwarded-* (see below)
RESOURCE_BOUND_TOKENSnoRFC 8707 tokens bound to /hub or one /<name>/mcp, default true; set false only to keep pre-0.5 unbound tokens working
MCP_BODY_LIMITnoauthenticated MCP JSON body limit, default 1mb
MCP_REQUESTS_PER_MINUTEnolimit per OAuth client, default 120
MCP_MAX_CONCURRENT_REQUESTSnoin-flight limit per OAuth client, default 4
HTTP_HEADERS_TIMEOUT_MSnoNode HTTP header timeout, default 10000
HTTP_REQUEST_TIMEOUT_MSnocomplete request timeout, default 310000 (slightly above the tool-call timeout)
PORTnolisten port (default 80 in the image, 3000 outside)
CONFIG_PATHnodefault /config/mcp.json
DATA_PATHnodefault /data
LOG_FILEnoadditionally mirror all log output into this file, e.g. /data/mcp-hub.log (see below)

/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.

docker pull ghcr.io/ni-c/mcp-hub:0.5.0

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:

services:
  mcp-hub:
    image: ghcr.io/ni-c/mcp-hub:0.5.0   # replaces `build: .`; pin a digest in production
    # ...rest of docker-compose.example.yml unchanged
cp docker-compose.example.yml docker-compose.yml   # adjust, swap build β†’ image
cp mcp.json.example mcp.json                        # adjust
mkdir -p data && sudo chown -R 1000:1000 data       # container runs as uid 1000
docker compose up -d

Or without compose:

mkdir -p data && sudo chown -R 1000:1000 data       # container runs as uid 1000
docker run -d --name mcp-hub \
  -p 127.0.0.1:7690:80 \
  -e EXTERNAL_URL="https://mcp.example.net" \
  -e PASSWORD_HASH="$(htpasswd -bnBC 10 '' 'yourpassword' | tr -d ':\n')" \
  -e TRUSTED_PROXIES="192.168.1.0/24" \
  -v "$PWD/mcp.json:/config/mcp.json:ro" \
  -v "$PWD/data:/data" \
  ghcr.io/ni-c/mcp-hub:0.5.0

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

cp docker-compose.example.yml docker-compose.yml   # adjust
cp mcp.json.example mcp.json                        # adjust
docker compose up -d --build

Option C β€” npm (without a container)

CONFIG_PATH=./mcp.json DATA_PATH=./data PASSWORD_HASH='...' \
  npx @ni-c/mcp-hub

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 from Claude Web: add a custom connector with URL https://<host>/hub (or https://<host>/<name>/mcp for one server), log in once with the password. Claude Code: claude mcp add -t http name https://<host>/<name>/mcp.

Each client is confirmed once. Entering the password approves the client that asked; while a login session is still valid, a client you have not seen before gets an explicit Approve / Deny page instead of a code. Approved clients reconnect silently from then on.

List clients or revoke one while the main container is stopped (both commands mount the same /data; stopping avoids concurrent state writers):

docker compose stop mcp-hub
docker compose run --rm --no-deps mcp-hub node /app/dist/admin.js clients list
docker compose run --rm --no-deps mcp-hub node /app/dist/admin.js clients revoke CLIENT_ID
docker compose up -d

Revocation removes the approval and all refresh tokens and immediately rejects already-issued access tokens. The next connection needs explicit approval.

Endpoints

PathAuthPurpose
/<name>, /<name>/mcpBearerStreamable HTTP endpoint of one server
/hubBeareraggregate endpoint with the 4 meta-tools
/liveznoneminimal process liveness (200)
/healthBearerper-server status (200 all up / 503 degraded)
/authorize, /token, /register, /login, /consent, /revokeβ€”OAuth 2.1 + DCR
/.well-known/oauth-authorization-server[/…]noneRFC 8414 metadata
/.well-known/oauth-protected-resource[/…]noneRFC 9728 metadata (path-scoped)

Notes & limitations

  • Stateless transport: server-initiated notifications (listChanged, subscriptions, sampling) are not delivered to clients. Tool/resource/prompt request-response works fully; the hub's tool cache does follow tools/list_changed internally.
  • Access tokens are self-contained 15-minute JWTs. Revoking a client rejects its existing JWTs and removes all of its refresh tokens. Refresh tokens rotate; replaying a token that was already rotated away revokes its whole chain, and a refresh cannot ask for more scope than the original grant.
  • Upstream auth is fully decoupled from the hub's own OAuth: an expired upstream token just marks that one server down (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:

# /etc/fail2ban/filter.d/mcp-hub-auth.conf
[Definition]
failregex = mcp-hub: authentication failure from <HOST>\s*$
            mcp-hub: login rate limit exceeded from <HOST>\s*$
            mcp-hub: consent with an invalid CSRF token from <HOST>\s*$
ignoreregex =

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

npm install
npm test           # vitest: config, OAuth flow, proxy E2E, hub, hot reload
npm run dev        # tsx, needs EXTERNAL_URL/PASSWORD/CONFIG_PATH/DATA_PATH

Reviews

No reviews yet

Be the first to review this server!

Hub MCP Server - Serve many stdio MCP servers from one container over HTTPS, | MCP Marketplace