{
  "markdown": "# mcp-hub\n\n<!-- badges: start -->\n\n[![CI](https://img.shields.io/github/actions/workflow/status/ni-c/mcp-hub/ci.yml?branch=main&label=CI)](https://github.com/ni-c/mcp-hub/actions/workflows/ci.yml)\n[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/ni-c/mcp-hub/badge)](https://scorecard.dev/viewer/?uri=github.com/ni-c/mcp-hub)\n<a href=\"https://socket.dev/npm/package/@ni-c/mcp-hub\"><img src=\"https://socket.dev/api/badge/npm/package/@ni-c/mcp-hub\" alt=\"Socket supply-chain report\" height=\"20\"></a>\n[![Glama score](https://glama.ai/mcp/servers/ni-c/mcp-hub/badges/score.svg)](https://glama.ai/mcp/servers/ni-c/mcp-hub)\n<br>\n[![npm version](https://img.shields.io/npm/v/%40ni-c%2Fmcp-hub)](https://www.npmjs.com/package/@ni-c/mcp-hub)\n[![container image](https://img.shields.io/badge/ghcr.io-ni--c%2Fmcp--hub-4f46e5?logo=docker&logoColor=white)](https://github.com/ni-c/mcp-hub/pkgs/container/mcp-hub)\n<br>\n[![docs](https://img.shields.io/badge/docs-mcp--hub.ni--c.de-4f46e5?logo=readthedocs&logoColor=white)](https://mcp-hub.ni-c.de)\n[![sponsor](https://img.shields.io/badge/sponsor-ni--c-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/ni-c)\n<!-- badges: end -->\n\nA dual-era [Model Context Protocol](https://modelcontextprotocol.io) (MCP) gateway: it\nserves many stdio MCP servers from **one container**, published over HTTPS, and speaks\n**both MCP revisions on every endpoint** — `2026-07-28` and `2025-11-25`. The client\npicks, and cannot tell which one it is on from the answers. On the 2026 revision that\nincludes **elicitation** — a child server's question reaches the person at the far end\ninstead of dying at the gateway\n([how](https://mcp-hub.ni-c.de/guide/elicitation)) — and **subscriptions**: the hub\nserves `subscriptions/listen` to its clients and subscribes to its children on\nwhichever revision _they_ speak, so a server that has never heard of it still reaches\na client that speaks nothing else\n([how](https://mcp-hub.ni-c.de/guide/subscriptions)).\n\nLets MCP clients that cannot spawn a local process — ChatGPT connectors, Claude on\nthe Web and in Code, Mistral Le Chat, Cursor, LibreChat and any other\nStreamable-HTTP client — reach every server behind it, with a built-in OAuth 2.1\nlogin protected by a single password, plus long-lived API tokens for clients that\ncannot do OAuth (OpenAI Responses API, xAI API, Gemini API). Per-client recipes:\n[client compatibility](https://mcp-hub.ni-c.de/guide/client-compatibility).\n\n<!-- <picture> is resolved against the colour scheme of the page showing it, so GitHub\n     picks the variant that matches its own theme toggle. npm strips <picture> and\n     <source> when it sanitises the README and keeps the <img>, which is why that\n     fallback brings its own dark card instead of relying on a media query. -->\n<picture>\n  <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://mcp-hub.ni-c.de/architecture-dark.svg\">\n  <source media=\"(prefers-color-scheme: light)\" srcset=\"https://mcp-hub.ni-c.de/architecture-light.svg\">\n  <img src=\"https://mcp-hub.ni-c.de/architecture.svg\" alt=\"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\" width=\"800\">\n</picture>\n\n![Demo: config in, hub up, servers reachable through one endpoint](https://mcp-hub.ni-c.de/demo.gif)\n\n**Want to poke at it first?** [`demo/`](demo/) is a throwaway hub with three\nfake servers — `docker compose up -d`, then point the\n[MCP Inspector](https://mcp-hub.ni-c.de/guide/demo#mcp-inspector) or\n[MCPJam](https://mcp-hub.ni-c.de/guide/demo#mcpjam) at it. Nothing to\nconfigure, nothing to clean up but a volume.\n\n## Why\n\nWrapping each stdio MCP server in its own auth-proxy container costs a full\nimage, an OAuth stack, a hostname and a compose stack _per server_. mcp-hub\nreplaces N containers with one process:\n\n- **Config is exactly Claude Code's `mcpServers` format** — copy entries 1:1.\n- **Path-based routing**: `https://host/paperless`, `https://host/homeassistant`, …\n- **`/hub` aggregate**: register a _single_ connector and reach every server\n  through 6 meta-tools (`list_servers`, `list_tools`, `get_tool_schema`,\n  `call_tool`, `wake_server`, `sleep_server`) without flooding the model\n  context with N×tools schemas.\n- **Per-server tool filtering**: `allowTools` / `denyTools` on any server decide\n  which of its tools the hub exposes — exact names or `list_*` prefixes. A\n  filtered tool is hidden from `tools/list` _and_ refused if a client calls it\n  anyway, before the server is even woken, so a client holding a stale schema\n  cannot reach it.\n- **Also without HTTP**: `mcp-hub --stdio` serves that same aggregate on\n  stdin/stdout for clients that can only spawn a local process (Claude Desktop,\n  Codex, …) — same `mcp.json`, no TLS, no reverse proxy, no login. Auth exists\n  for the network endpoints; over stdio the trust boundary is the local user.\n- **On-demand lifecycle**: stdio and docker servers start when used and sleep\n  after 60 idle minutes, answering `initialize`/`tools/list` from a persistent\n  snapshot meanwhile — a dozen servers cost only the memory of the ones in\n  use. `keepAlive: true` exempts a server, `IDLE_TIMEOUT_MINUTES=0` the hub.\n- **CIMD-first OAuth 2.1**: clients identify themselves with a [Client ID\n  Metadata Document](https://mcp-hub.ni-c.de/guide/client-registration) — the\n  registration-free path the MCP spec now prefers — including `private_key_jwt`\n  against the keys in their own document (metadata-document clients only). RFC\n  7591 dynamic registration stays advertised beside it for older clients,\n  `mcp-hub-admin clients add` issues credentials by hand for anything that can\n  do neither, and `CLIENT_REGISTRATION` turns either mechanism off.\n- **OAuth outwards, too**: a remote server that speaks OAuth gets an `oauth`\n  block instead of a static header. The hub registers itself — with credentials\n  the upstream issued, via RFC 7591, or with its own client metadata document —\n  then obtains and refreshes the token. `client_credentials` upstreams need no\n  attention at all; where a person must sign in, `mcp-hub-admin upstream login`\n  prints one URL. An upstream that needs re-authorizing shows up as one server\n  `unauthorized`, not as a confusing 401 in your client.\n- **Supervision**: children are pinged and restarted with exponential backoff\n  when they die. A down server answers 503, not silence; a crash-looping\n  server nobody uses is parked instead of restarted forever.\n- **Hot reload**: edits to `mcp.json` start/stop/restart only the affected\n  servers.\n- **Stateless Streamable HTTP**: no session state, so claude.ai's\n  reconnect-without-DELETE behaviour cannot leak processes or memory.\n- **Dual-era**: every endpoint — `/hub`, `/<name>/mcp` and `--stdio` — answers\n  MCP `2026-07-28` and `2025-11-25` alike; the client picks and cannot tell\n  from the answers which it got. On the 2026 revision that includes\n  **elicitation**: a server asking the user something returns the question\n  rather than pushing it, so it reaches the person at the far end instead of\n  dying at the gateway. The hub attributes it to the server that asked, strips\n  what could lie about that, drops embedded sampling and roots requests, and\n  seals the resumption state against the call it belongs to. `passthrough:\n\"off\"` withdraws one server's right to ask;\n  [details](https://mcp-hub.ni-c.de/guide/elicitation).\n- **Change notifications, in both eras**: a client opens a\n  `subscriptions/listen` stream and hears when a child's tools, prompts or\n  resources change. The hub subscribes to each child the way that child\n  understands — `subscriptions/listen` to a 2026 server, `resources/subscribe`\n  to a 2025 one — so the era gap is the gateway's problem rather than either\n  end's. The state is the open response, not a session table, so this costs the\n  stateless design nothing. A sleeping server watches nothing and is told to\n  re-read on waking; `subscriptions: \"off\"` withdraws one server's right to\n  push; [details](https://mcp-hub.ni-c.de/guide/subscriptions).\n- **Lightweight by design**: one Node process, no database (state is one JSON\n  file plus a signing key under `/data`), ten runtime dependencies, and\n  multi-arch images — a stated project goal is to run comfortably on a\n  single-board computer like a Raspberry Pi.\n\n## Servers to run behind it\n\nThe hub is server-agnostic — it serves any stdio MCP server whose entry fits\nClaude Code's `mcpServers` format, which is most of them. These nineteen are\nbuilt and maintained alongside it, so their documentation carries the hub entry\nyou need and their tool filters line up with the hub's own `allowTools` /\n`denyTools`:\n\n| Server                                                                 | npm                               | What it reaches                                                                |\n| ---------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------ |\n| [audiobookshelf-mcp](https://audiobookshelf-mcp.ni-c.de)               | `audiobookshelf-mcp`              | Audiobookshelf — libraries, listening progress, collections and playlists      |\n| [caldav-mcp](https://caldav-mcp.ni-c.de)                               | `@ni-c/caldav-mcp`                | CalDAV — events, tasks and journal entries on any server that speaks it        |\n| [calibreweb-mcp](https://calibreweb-mcp.ni-c.de)                       | `calibreweb-mcp`                  | Calibre-Web — read-only library access through the OPDS feed                   |\n| [carddav-mcp](https://carddav-mcp.ni-c.de)                             | `@ni-c/carddav-mcp`               | CardDAV — contacts, groups and photos on any server that speaks it             |\n| [freshrss-mcp](https://freshrss-mcp.ni-c.de)                           | `@ni-c/freshrss-mcp`              | FreshRSS — feeds, categories and articles as plain text, not stream ids        |\n| [google-search-console-mcp](https://google-search-console-mcp.ni-c.de) | `@ni-c/google-search-console-mcp` | Google Search Console — properties, sitemaps, search analytics, URL inspection |\n| [healthchecks-mcp](https://healthchecks-mcp.ni-c.de)                   | `healthchecks-mcp`                | Healthchecks — cron and uptime checks, and why one failed                      |\n| [hetzner-dns-mcp](https://hetzner-dns-mcp.ni-c.de)                     | `hetzner-dns-mcp`                 | Hetzner Cloud DNS — zones, record sets and BIND import/export                  |\n| [imap-mcp](https://imap-mcp.ni-c.de)                                   | `@ni-c/imap-mcp`                  | IMAP mailboxes — read, search, organise and draft mail; it cannot send         |\n| [linkwarden-mcp](https://linkwarden-mcp.ni-c.de)                       | `linkwarden-mcp`                  | Linkwarden — bookmarks, collections and the article text it preserved          |\n| [mealie-mcp](https://mealie-mcp.ni-c.de)                               | `@ni-c/mealie-mcp`                | Mealie — recipes, meal plans, shopping lists and cookbooks                     |\n| [ntfy-mcp](https://ntfy-mcp.ni-c.de)                                   | `@ni-c/ntfy-mcp`                  | ntfy — publish and update notifications, manage users and topic access         |\n| [opengist-mcp](https://opengist-mcp.ni-c.de)                           | `opengist-mcp`                    | Opengist — gists, revisions, commit history and raw files                      |\n| [osm-mcp](https://osm-mcp.ni-c.de)                                     | `osm-mcp`                         | OpenStreetMap — geocoding, routing, isochrones and POI search                  |\n| [rustpad-mcp](https://rustpad-mcp.ni-c.de)                             | `rustpad-mcp`                     | Rustpad — collaborative pads edited through real OT, not overwrites            |\n| [smtp-mcp](https://smtp-mcp.ni-c.de)                                   | `@ni-c/smtp-mcp`                  | SMTP — sends mail, behind a recipient allowlist and a human confirmation       |\n| [wg-easy-mcp](https://wg-easy-mcp.ni-c.de)                             | `wg-easy-mcp`                     | wg-easy v15+ — the full WireGuard client lifecycle                             |\n| [wikijs-mcp](https://wikijs-mcp.ni-c.de)                               | `@ni-c/wikijs-mcp`                | Wiki.js — search, read and edit pages, plus assets, users and groups           |\n| [woodpecker-ci-mcp](https://woodpecker-ci-mcp.ni-c.de)                 | `@ni-c/woodpecker-ci-mcp`         | Woodpecker CI — repositories, pipelines, logs, secrets and crons               |\n\nEach one runs perfectly well on its own over stdio. Put them behind the hub when\nyou want them reachable from a client that cannot spawn a local process, or when\nyou would rather register one connector than nineteen.\n\n## Configuration\n\n`/config/mcp.json` — identical to Claude Code (`${VAR}` expands from the\ncontainer environment; unknown fields are ignored by Claude Code, so the file\nstays interchangeable). Install stdio server binaries at a reviewed, exact\nversion in your image; do not download mutable packages at runtime:\n\n```json\n{\n  \"mcpServers\": {\n    \"paperless\": {\n      \"command\": \"paperless-mcp\",\n      \"args\": [],\n      \"env\": { \"PAPERLESS_API_TOKEN\": \"${PAPERLESS_API_TOKEN}\" }\n    },\n    \"homeassistant\": {\n      \"type\": \"http\",\n      \"url\": \"http://homeassistant:8123/api/mcp\",\n      \"headers\": { \"Authorization\": \"Bearer ${HA_TOKEN}\" }\n    },\n    \"private-thing\": { \"command\": \"some-mcp\", \"args\": [], \"hub\": false },\n    \"paperless-readonly\": {\n      \"command\": \"paperless-mcp\",\n      \"allowTools\": [\"search_*\", \"get_document\"],\n      \"denyTools\": [\"delete_document\"]\n    },\n    \"untrusted\": {\n      \"type\": \"docker\",\n      \"image\": \"ghcr.io/example/untrusted-mcp@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\n      \"network\": \"none\",\n      \"memory\": \"256m\"\n    }\n  }\n}\n```\n\nStdio servers (`command`/`args`/`env`) are spawned as supervised child\nprocesses. Remote servers (`type: \"http\"` or `\"sse\"` with `url` and optional\n`headers`) are connected as MCP clients with the configured headers injected\non every request — the same supervision (ping, backoff reconnect, hot reload)\napplies. An upstream that speaks OAuth gets an `oauth` block instead of a\nheader: the hub registers itself (statically, via RFC 7591 or via a client\nmetadata document), obtains the token and refreshes it, with one browser visit\nstarted from the admin CLI where the grant needs a person.\n`\"hub\": false` hides a server from the `/hub` aggregate; its own path keeps\nworking. `allowTools` / `denyTools` cut finer and apply to every kind of\nserver: a filtered tool is absent from both `tools/list` and `/hub`, and is\nrefused if called anyway — before the server is woken.\nReserved names: `mcp`, `hub`, `authorize`, `token`, `register`,\n`login`, `consent`, `health`, `livez`, `revoke`, `jwks`, `interaction`,\n`session`, `userinfo`, `upstream`, `.well-known`.\n\nAll stdio children share the hub's Unix user and can read its mounted files.\nOnly install fully trusted stdio servers. A server with a different trust level\nbelongs in its own container — and it does not have to speak HTTP to get there:\n\n- `type: \"docker\"` — the hub creates the container and talks **stdio across the\n  container boundary** over the Docker API. No HTTP listener, no bearer token,\n  no bridge process in the image. The hub itself never gets the Docker socket:\n  a separate `mcp-hub-docker-proxy` container holds it and allows only the\n  container operations `mcp.json` describes — nothing privileged, no host\n  mounts, no other images. Credentials can live with the proxy (`secretsFrom`)\n  so the hub process never holds them — and rotating one is just an edit: the\n  proxy watches the file and recreates the sandbox with the new values.\n- `type: \"unix\"` / `\"tcp\"` — you start the container, the hub connects to a\n  socket. Costs the hub no privileges at all, and a Unix socket reaches a\n  sandbox running with `network_mode: none`.\n\nBoth carry the newline-delimited JSON-RPC the specification asks custom\ntransports to reuse. See [sandboxing](https://mcp-hub.ni-c.de/guide/sandboxing)\nand [SECURITY.md](SECURITY.md).\n\nFor a custom image, pin every package to an exact version:\n\n```dockerfile\nFROM ghcr.io/ni-c/mcp-hub:0.10.0   # pin @sha256:<digest> in production\nUSER root\nRUN npm install -g your-mcp-package@1.2.3\nUSER node\n```\n\n### Environment\n\nWithout a non-empty `PASSWORD` or a bcrypt `PASSWORD_HASH` the HTTP hub still\nstarts, but its login is disabled: the startup log says so, the sign-in page\nanswers `503` with the reason, and no client can be approved — so no token can\nbe issued. A configured hash takes precedence, and a hash that is not a bcrypt\nhash disables the login the same way rather than falling back to `PASSWORD`.\nThe local `--stdio` mode uses neither variable.\n\n| Variable                        | Required            | Description                                                                                                                   |\n| ------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------- |\n| `EXTERNAL_URL`                  | yes                 | Public base URL, e.g. `https://mcp.example.net` (no path)                                                                     |\n| `PASSWORD_HASH`                 | one of              | bcrypt hash of the login password (`htpasswd -bnBC 10 \"\" 'pw' \\| tr -d ':\\n'`)                                                |\n| `PASSWORD`                      | one of              | plain-text alternative to `PASSWORD_HASH`                                                                                     |\n| `TRUSTED_PROXIES`               | no                  | comma-separated IPs/CIDRs allowed to set `X-Forwarded-*` (see below)                                                          |\n| `RESOURCE_BOUND_TOKENS`         | no                  | RFC 8707 tokens bound to `/hub` or one `/<name>/mcp`, default `true`; set `false` only to keep pre-0.5 unbound tokens working |\n| `DEFAULT_RESOURCE`              | no                  | server name (or `hub`) to bind tokens to when a client sends no `resource` parameter; unset → such requests are refused       |\n| `MCP_BODY_LIMIT`                | no                  | authenticated MCP JSON body limit, default `1mb`                                                                              |\n| `MCP_REQUESTS_PER_MINUTE`       | no                  | limit per OAuth client, default `120`                                                                                         |\n| `MCP_MAX_CONCURRENT_REQUESTS`   | no                  | in-flight request limit per OAuth client, default `4`                                                                         |\n| `MCP_MAX_CONCURRENT_STREAMS`    | no                  | open SSE listening streams per OAuth client — one per connected session, default `32`                                         |\n| `HTTP_HEADERS_TIMEOUT_MS`       | no                  | Node HTTP header timeout, default `10000`                                                                                     |\n| `HTTP_REQUEST_TIMEOUT_MS`       | no                  | complete request timeout, default `310000` (slightly above the tool-call timeout)                                             |\n| `PORT`                          | no                  | listen port (default 80 in the image, 3000 outside)                                                                           |\n| `CONFIG_PATH`                   | no                  | default `/config/mcp.json`                                                                                                    |\n| `DATA_PATH`                     | no                  | default `/data`                                                                                                               |\n| `LOG_FILE`                      | no                  | additionally mirror all log output into this file, e.g. `/data/mcp-hub.log` (see below)                                       |\n| `CLIENT_REGISTRATION`           | no                  | which mechanisms a client may use for a `client_id`: `cimd`, `dcr` or both (default)                                          |\n| `CIMD_ALLOWED_ORIGINS`          | no                  | bare https origins whose metadata documents are accepted; unset → any                                                         |\n| `CIMD_ALLOW_PRIVATE_ADDRESSES`  | no                  | local development only; relaxes the SSRF guard, warns on every start                                                          |\n| `DCR_MAX_CLIENTS`               | no                  | ceiling on stored dynamic registrations, default `500`                                                                        |\n| `DCR_PENDING_TTL_HOURS`         | no                  | how long a never-approved registration is kept, default `24`                                                                  |\n| `DCR_INACTIVE_DAYS`             | no                  | how long an unused approved registration is kept, default `90`                                                                |\n| `IDLE_TIMEOUT_MINUTES`          | no                  | idle minutes before an on-demand server sleeps, default `60`; `0` disables it                                                 |\n| `TOOL_CACHE_PATH`               | no                  | snapshots of sleeping servers, default `<DATA_PATH>/tool-cache.json`                                                          |\n| `MCP_CALL_TIMEOUT_MS`           | no                  | deadline for one forwarded tool call, default `300000`                                                                        |\n| `MCP_RESET_TIMEOUT_ON_PROGRESS` | no                  | let progress notifications extend that deadline, default `false`                                                              |\n| `DOCKER_HOST`                   | with docker servers | the **policy proxy's** socket; a direct daemon socket fails closed                                                            |\n\nThe full table, including what applies in stdio mode, is in the\n[environment reference](https://mcp-hub.ni-c.de/reference/environment).\n\n`/data` holds the Ed25519 JWT key, registered OAuth clients, approvals and\nrefresh tokens. **Mount it as a volume** — recreating it invalidates every\nconnector authorization.\n\nEvery access token is bound to one resource. The OAuth client includes the\nresource advertised by the endpoint's RFC 9728 document — no client-side\nconfiguration needed — and the resulting token is valid only there: a token for\n`/paperless/mcp` cannot call `/hub`, `/health` or another server. The shorter\n`/<name>` route is canonicalized to `/<name>/mcp`.\n\n`RESOURCE_BOUND_TOKENS=false` turns this off and is a migration mode for\ndeployments from 0.4 and earlier, where tokens were issued without a resource\nand reach every path. The hub logs a warning while it is set. Removing it\ninvalidates those unbound tokens, so every connector authorizes once more.\n\n`TRUSTED_PROXIES` decides what `req.ip` is, and therefore what the login rate\nlimiter counts. List **only** your own reverse proxy, and make sure it\n_overwrites_ `X-Forwarded-For` rather than appending to it — otherwise a\nclient can supply its own address and rotate it to sidestep the per-IP limit.\nIf the variable is unset, every request appears to come from the proxy and\nper-IP limiting degrades to a single global counter (the hub logs a warning\nat startup). A global cap of 100 failures per 15 minutes applies either way.\n\n## Running\n\n### Option A — prebuilt image from GHCR (recommended)\n\nPublished on every push to `main` and every `vX.Y.Z` release tag, for\n`linux/amd64` and `linux/arm64`. Browse the versions on the\n[package page](https://github.com/ni-c/mcp-hub/pkgs/container/mcp-hub).\n\n```sh\ndocker pull ghcr.io/ni-c/mcp-hub:0.10.0\n```\n\nTags: `latest` (tip of `main`), `X.Y.Z` and `X.Y` (releases), and\n`sha-<commit>` for a specific build.\n\nUse a version tag instead of `latest` for controlled updates. For an immutable\ndeployment, record the resolved digest from `docker image inspect` and use\n`ghcr.io/ni-c/mcp-hub:<version>@sha256:<digest>` in Compose.\n\nWith compose, copy the example and point it at the image instead of building:\n\n```yaml\nservices:\n  mcp-hub:\n    image: ghcr.io/ni-c/mcp-hub:0.10.0 # replaces `build: .`; pin a digest in production\n    # ...rest of docker-compose.example.yml unchanged\n```\n\n```sh\ncp docker-compose.example.yml docker-compose.yml   # adjust, swap build → image\nmkdir -p config && cp mcp.json.example config/mcp.json  # adjust\nmkdir -p data && sudo chown -R 1000:1000 data       # container runs as uid 1000\ndocker compose up -d\n```\n\nOr without compose:\n\n```sh\nmkdir -p data && sudo chown -R 1000:1000 data       # container runs as uid 1000\ndocker run -d --name mcp-hub \\\n  -p 127.0.0.1:7690:80 \\\n  -e EXTERNAL_URL=\"https://mcp.example.net\" \\\n  -e PASSWORD_HASH=\"$(htpasswd -bnBC 10 '' 'yourpassword' | tr -d ':\\n')\" \\\n  -e TRUSTED_PROXIES=\"192.168.1.0/24\" \\\n  -v \"$PWD/config:/config:ro\" \\\n  -v \"$PWD/data:/data\" \\\n  ghcr.io/ni-c/mcp-hub:0.10.0\n```\n\nUpdate to a newer image with `docker compose pull && docker compose up -d`\n(or `docker pull …`, then recreate the container).\n\n### Option B — build from source\n\n```sh\ncp docker-compose.example.yml docker-compose.yml   # adjust\nmkdir -p config && cp mcp.json.example config/mcp.json  # adjust\ndocker compose up -d --build\n```\n\n### Option C — npm (without a container)\n\n```sh\nCONFIG_PATH=./mcp.json DATA_PATH=./data PASSWORD_HASH='...' \\\n  npx @ni-c/mcp-hub\n```\n\nInstalls as [`@ni-c/mcp-hub`](https://www.npmjs.com/package/@ni-c/mcp-hub)\n(the unscoped npm name belongs to an unrelated project) and provides the\n`mcp-hub` and `mcp-hub-admin` binaries. The container remains the recommended\ndeployment — it provides the isolation, read-only root filesystem and resource\nlimits that SECURITY.md assumes.\n\nReverse-proxy requirements: TLS termination, WebSockets/SSE allowed (proxy\nbuffering off, a request timeout above 310 seconds, a request-body limit at or\nbelow `MCP_BODY_LIMIT`, and pass `X-Forwarded-Proto`/`Host`.\n\nConnect a client: add `https://<host>/hub` (or `https://<host>/<name>/mcp`\nfor one server) as a custom connector — in ChatGPT (developer mode), Claude\nWeb, Mistral Le Chat, Cursor, LibreChat or any other OAuth-capable MCP client —\nand log in once with the password. Claude Code:\n`claude mcp add -t http name https://<host>/<name>/mcp`. API-only clients\n(OpenAI Responses API, xAI, Gemini API) use an admin-minted token instead —\nsee [client compatibility](https://mcp-hub.ni-c.de/guide/client-compatibility).\n\nEach client is confirmed once. Entering the password approves the client that\nasked; while a login session is still valid, a client you have not seen before\ngets an explicit _Approve / Deny_ page instead of a code. Approved clients\nreconnect silently from then on.\n\nList clients or revoke one. The CLI shares `/data` with the running hub and\nboth sides re-read the state file before they touch it, so this works against a\nlive container — a revocation takes effect on the next request:\n\n```sh\ndocker exec mcp-hub node /app/dist/admin.js clients list\ndocker exec mcp-hub node /app/dist/admin.js clients revoke CLIENT_ID\ndocker exec mcp-hub node /app/dist/admin.js clients delete CLIENT_ID\ndocker exec mcp-hub node /app/dist/admin.js clients prune --dry-run\n```\n\nRevocation removes the approval and all refresh tokens and immediately rejects\nalready-issued access tokens. The next connection needs explicit approval.\n`delete` goes further and removes the registration itself, and `prune` applies\nthe [registration lifecycle rules](https://mcp-hub.ni-c.de/guide/client-registration)\non demand — registrations that were never approved expire after a day, unused\nones after 90 days, and a dynamically registered client can also remove its own\nregistration through RFC 7592.\n\nFor clients that cannot do OAuth at all — the OpenAI Responses API, the xAI\nAPI, Gemini's `mcp_server` tool, plain-header clients — the same CLI mints\nlong-lived, resource-bound API tokens:\n\n```sh\ndocker exec mcp-hub node /app/dist/admin.js tokens create --resource hub --days 90 --label \"openai\"\ndocker exec mcp-hub node /app/dist/admin.js tokens list\ndocker exec mcp-hub node /app/dist/admin.js tokens revoke TOKEN_ID\n```\n\nThe token is printed once and never stored; `tokens revoke` takes effect\nimmediately. Per-client recipes:\n[client compatibility](https://mcp-hub.ni-c.de/guide/client-compatibility).\n\n## Endpoints\n\n| Path                                                                 | Auth                       | Purpose                                                           |\n| -------------------------------------------------------------------- | -------------------------- | ----------------------------------------------------------------- |\n| `/<name>`, `/<name>/mcp`                                             | Bearer                     | Streamable HTTP endpoint of one server                            |\n| `/hub`                                                               | Bearer                     | aggregate endpoint with the 6 meta-tools                          |\n| `/livez`                                                             | none                       | minimal process liveness (`200`)                                  |\n| `/health`                                                            | Bearer                     | per-server status (`200` all up / `503` degraded)                 |\n| `/authorize`, `/token`, `/register`, `/revoke`, `/interaction/<uid>/…` | —                          | OAuth 2.1 · CIMD + DCR; the login and consent pages live under `/interaction/` |\n| `/register/<client_id>`                                              | registration token         | RFC 7592: a client reads, changes or removes its own registration |\n| `/upstream/callback`                                                 | signed state + hub session | where an upstream returns after `upstream login`                  |\n| `/.well-known/mcp-hub-client/<id>.json`                              | none                       | the hub's own client metadata document, one per `cimd` upstream   |\n| `/.well-known/oauth-authorization-server[/…]`                        | none                       | RFC 8414 metadata                                                 |\n| `/.well-known/oauth-protected-resource[/…]`                          | none                       | RFC 9728 metadata (path-scoped)                                   |\n\n## Notes & limitations\n\n- Change notifications (`listChanged`, resource updates) are carried on\n  `2026-07-28` via `subscriptions/listen`, whose state is the open response\n  rather than a session table. A `2025-11-25` client is offered neither, because\n  that revision needs a channel the stateless transport does not keep — so the\n  capability is withheld instead of announced and dropped. An on-demand server\n  watches nothing while it sleeps; the subscription is re-established on the\n  next wake and the client is told to re-read.\n- Elicitation travels end to end on `2026-07-28`: it is a result rather than a\n  push. Sampling and log messages are not forwarded.\n- Access tokens are opaque and last 15 minutes. Revoking a client takes effect\n  on its next request rather than when the token expires — the token is a\n  reference to a stored record, so withdrawing it is a deletion. Refresh tokens\n  rotate; replaying one that was already rotated away is treated as a leak and\n  revokes the whole grant, access tokens included.\n- Upstream auth is fully decoupled from the hub's own OAuth: an expired\n  upstream token just marks that one server `unauthorized` (503 on its path, visible\n  in `/health`) — clients never see the upstream's 401.\n- One login can approve multiple connectors, but each token is valid only for\n  its requested server or `/hub`. Registration remains open as the MCP\n  specification intends; a client only receives codes after confirmation and\n  only at the confirmed redirect target.\n- Failed logins are rate-limited (10/15 min per IP) and logged as\n  `mcp-hub: authentication failure from <ip>` for fail2ban.\n- Auth pages deny framing and carry a restrictive CSP. MCP bodies are parsed\n  only after bearer verification and are bounded by size, per-client request\n  rate and per-client concurrency.\n\n### Logging to a file for fail2ban\n\n`LOG_FILE=/data/mcp-hub.log` mirrors every log line into that file, one line\nper entry with an ISO-8601 UTC prefix, while leaving the console output alone —\nso `docker logs` keeps working. A jail then reads the file directly:\n\n```ini\n# /etc/fail2ban/filter.d/mcp-hub-auth.conf\n[Definition]\nfailregex = mcp-hub: authentication failure from <HOST>\\s*$\n            mcp-hub: login rate limit exceeded from <HOST>\\s*$\n            mcp-hub: consent with an invalid CSRF token from <HOST>\\s*$\nignoreregex =\n```\n\nOnly the hub's own lines are mirrored — the stdio children inherit stderr\ndirectly, so their output stays in the container log and the file stays small.\nRotate it with logrotate (`copytruncate`, since the hub holds the file open).\n\nWhy not read the container's own logs instead: the Docker `json-file` path\ncontains the container ID and changes on every recreate, and the `journald`\ndriver maps **all** stderr to priority `err` — since an MCP server must keep\nstdout free for the protocol and therefore logs to stderr, every ordinary line\nwould show up as a system error and drown out host monitoring.\n\nBans belong in the `DOCKER-USER` chain (`banaction = iptables-allports`) when\nthe hub is published through a container-based reverse proxy: that traffic\narrives via DNAT and `FORWARD`, and never passes `INPUT`.\n\n## Development\n\n```sh\nnpm install\nnpm test           # vitest: config, OAuth flow, proxy E2E, hub, hot reload\nnpm run dev        # tsx, needs EXTERNAL_URL/PASSWORD/CONFIG_PATH/DATA_PATH\n```\n",
  "bytes": 34446,
  "sha": "834e2ec80b1856c32a57ae282dd4f70fa2e6c8c700c059e516060aa5a206ec6f",
  "repo_slug": "ni-c/mcp-hub",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_ni_c_mcp_hub_2c0c344e/readme"
}