@ni-c/mcp-hub 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/README.md +114 -54
  3. package/dist/admin.js +231 -7
  4. package/dist/admin.js.map +1 -1
  5. package/dist/auth/address.js +89 -0
  6. package/dist/auth/address.js.map +1 -0
  7. package/dist/auth/cimd.js +367 -0
  8. package/dist/auth/cimd.js.map +1 -0
  9. package/dist/auth/consent-page.js +4 -7
  10. package/dist/auth/consent-page.js.map +1 -1
  11. package/dist/auth/login-page.js +4 -7
  12. package/dist/auth/login-page.js.map +1 -1
  13. package/dist/auth/page.js +22 -0
  14. package/dist/auth/page.js.map +1 -1
  15. package/dist/auth/pinned-fetch.js +129 -0
  16. package/dist/auth/pinned-fetch.js.map +1 -0
  17. package/dist/auth/private-key-jwt.js +213 -0
  18. package/dist/auth/private-key-jwt.js.map +1 -0
  19. package/dist/auth/provider.js +102 -45
  20. package/dist/auth/provider.js.map +1 -1
  21. package/dist/auth/rate-limit.js +43 -0
  22. package/dist/auth/rate-limit.js.map +1 -0
  23. package/dist/auth/redirect-uri.js +58 -0
  24. package/dist/auth/redirect-uri.js.map +1 -0
  25. package/dist/auth/registration.js +146 -0
  26. package/dist/auth/registration.js.map +1 -0
  27. package/dist/auth/routes.js +67 -41
  28. package/dist/auth/routes.js.map +1 -1
  29. package/dist/auth/signed-token.js +49 -0
  30. package/dist/auth/signed-token.js.map +1 -0
  31. package/dist/auth/store.js +357 -5
  32. package/dist/auth/store.js.map +1 -1
  33. package/dist/auth/text.js +51 -0
  34. package/dist/auth/text.js.map +1 -0
  35. package/dist/config.js +130 -4
  36. package/dist/config.js.map +1 -1
  37. package/dist/health.js +22 -3
  38. package/dist/health.js.map +1 -1
  39. package/dist/hub.js +28 -0
  40. package/dist/hub.js.map +1 -1
  41. package/dist/index.js +123 -10
  42. package/dist/index.js.map +1 -1
  43. package/dist/proxy.js +25 -3
  44. package/dist/proxy.js.map +1 -1
  45. package/dist/stdio.js +27 -2
  46. package/dist/stdio.js.map +1 -1
  47. package/dist/supervisor.js +152 -8
  48. package/dist/supervisor.js.map +1 -1
  49. package/dist/tool-filter.js +58 -0
  50. package/dist/tool-filter.js.map +1 -0
  51. package/dist/upstream/auth.js +435 -0
  52. package/dist/upstream/auth.js.map +1 -0
  53. package/dist/upstream/login.js +94 -0
  54. package/dist/upstream/login.js.map +1 -0
  55. package/dist/upstream/provider.js +286 -0
  56. package/dist/upstream/provider.js.map +1 -0
  57. package/dist/upstream/routes.js +96 -0
  58. package/dist/upstream/routes.js.map +1 -0
  59. package/package.json +5 -2
package/CHANGELOG.md CHANGED
@@ -7,6 +7,237 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  <!-- #region changelog -->
9
9
 
10
+ ## [0.10.0] - 2026-08-27
11
+
12
+ ### Added
13
+
14
+ - `allowTools` and `denyTools` on any server in `mcp.json` decide which of its
15
+ tools the hub exposes. Each entry is an exact tool name or a prefix with a
16
+ single trailing `*`; the allow list decides what is in and the deny list is
17
+ subtracted from it. They apply to every kind of server — stdio, remote, docker
18
+ and socket — because an upstream you do not control is the strongest case for
19
+ filtering one. Nothing changes for a server that sets neither.
20
+
21
+ **It is a boundary, not a tidy-up.** A filtered tool is absent from
22
+ `tools/list` on the server's own path and from `list_tools` on `/hub`, and a
23
+ client that calls it anyway is refused on both routes — before the server is
24
+ woken, so a forbidden name cannot cost a container start. The refusal is the
25
+ same "unknown tool" a server gives for a name it never had: `/hub` tokens go
26
+ to third-party connectors, and enumerating what was hidden would be a
27
+ disclosure in itself.
28
+
29
+ Unlike ni-c's own MCP servers, an entry that matches no tool is not a config
30
+ error — the hub only learns an upstream's tools once it has connected. The
31
+ supervisor logs it at the moment it filters, and `/health` carries `exposed`,
32
+ `hidden` and `unmatched` per filtered server. The latter two only once the
33
+ server has really listed its tools: a snapshot restored from the tool cache is
34
+ already filtered, so `/health` omits them rather than reporting a zero it did
35
+ not earn.
36
+
37
+ Filters tools only: resources, resource templates and prompts on a per-server
38
+ path are untouched. It also does not shrink what the hub accepts — the size
39
+ limits on a `tools/list` answer are measured against the raw upstream, so a
40
+ server that blows them still fails as a whole.
41
+
42
+ - **Client ID Metadata Documents (CIMD), the registration mechanism the MCP
43
+ specification now prefers.** A client may use an HTTPS URL as its `client_id`
44
+ and host its own metadata there; the hub fetches that document, checks that it
45
+ vouches for itself and takes the client's name and redirect URIs from it.
46
+ Nothing is registered and nothing is stored, so a client that reinstalls or
47
+ moves to another machine is still recognised as the same client, and the
48
+ approval you gave it still holds. Dynamic registration remains available and
49
+ advertised beside it, so nothing that works today stops working: a
50
+ spec-compliant client picks CIMD on its own, everything else falls back.
51
+ Closes [#18](https://github.com/ni-c/mcp-hub/issues/18).
52
+
53
+ - **`private_key_jwt` client authentication.** A CIMD client cannot hold a
54
+ shared secret, so a confidential one proves itself with a JWT signed by a key
55
+ it publishes in its own document (`jwks` or `jwks_uri`). This is the path
56
+ ChatGPT's connectors take; without it they were refused with `invalid_client`.
57
+ The assertion must name the client as both `iss` and `sub`, target the token
58
+ endpoint or the issuer, carry a `jti` that is accepted exactly once, and
59
+ expire within five minutes.
60
+
61
+ - **`CLIENT_REGISTRATION`** names the mechanisms a client may use to obtain a
62
+ `client_id` — `cimd`, `dcr`, or both, which is the default. Dropping `dcr`
63
+ removes `registration_endpoint` from the discovery document and makes
64
+ `/register` answer `404`, which is how you retire dynamic registration once
65
+ every client you use supports CIMD. `CIMD_ALLOWED_ORIGINS` restricts which
66
+ origins may serve a metadata document (only origins can be pinned — ChatGPT's
67
+ per-connector document path is random), and `CIMD_ALLOW_PRIVATE_ADDRESSES`
68
+ relaxes the SSRF guard for local development only.
69
+
70
+ - **The authorization page names what cannot be forged.** For a
71
+ metadata-document client it shows the document URL under *Identified by*: the
72
+ name in that document is self-declared, the origin serving it is not. When
73
+ every redirect URI is a loopback address the page says so outright, because a
74
+ code sent to `http://127.0.0.1:…` could be collected by any program on that
75
+ machine.
76
+
77
+ - **The hub can authenticate itself to upstream MCP servers with OAuth.** A
78
+ remote server may carry an `oauth` block instead of a static `Authorization`
79
+ header, and the hub then obtains and refreshes the token itself — no
80
+ `mcp-remote` bridge, no token cache to babysit. It identifies itself with
81
+ credentials the upstream issued (`mode: "static"`, with an optional
82
+ `clientSecret` from `${VAR}`), by registering dynamically (`"dcr"`, RFC 7591)
83
+ or with its own client metadata document (`"cimd"`), and uses either the
84
+ `client_credentials` grant, which needs no attention at all, or
85
+ `authorization_code`, which needs one browser visit started with
86
+ `mcp-hub-admin upstream login <server>`. The CLI prints a URL, the upstream
87
+ redirects back to the hub, and the server connects. `upstream list`, `status`,
88
+ `register`, `refresh` and `logout` cover the rest; `logout` also revokes the
89
+ token (RFC 7009) and deletes a dynamic registration (RFC 7592) at the upstream.
90
+ Replaces the `mcp-remote` workaround the configuration guide used to recommend.
91
+
92
+ - **`mcp-hub-admin clients add`** issues a `client_id` and secret by hand, for a
93
+ client that supports neither dynamic registration nor a metadata document —
94
+ the one case that previously had no answer but an API token. Creating it
95
+ counts as approving it for the redirect URI you named, and it is exempt from
96
+ the lifecycle rules: nothing removes it but `clients delete`.
97
+
98
+ - **Outbound `private_key_jwt`.** An upstream can be told
99
+ `"clientAuth": "private_key_jwt"` and the hub signs an RFC 7523 assertion
100
+ instead of presenting a shared secret. The signing key lives at
101
+ `<DATA_PATH>/upstream-key.pem` and is deliberately not the key that signs the
102
+ hub's own access tokens; its public half travels with the client metadata
103
+ document or the registration request, which is how the upstream verifies it.
104
+
105
+ - **A client metadata document per upstream.** The hub previously published one
106
+ document built from the first server using `mode: "cimd"`, so a second such
107
+ server was registered with the first one's scopes. Each now has its own at
108
+ `/.well-known/mcp-hub-client/<id>.json`, where the identifier is derived from
109
+ the server name rather than being it — the URL is public, the names are not.
110
+
111
+ - **A remote server whose authorization is missing or refused enters a new
112
+ `unauthorized` state** instead of restarting every five minutes for ever. It
113
+ is reported in `/health` and `list_servers`, and the log names the command to
114
+ run. A completed login brings it up again without a restart of the hub.
115
+
116
+ - **A registration lifecycle for dynamic clients.** Anyone may register, so
117
+ registrations no longer stay forever: one that is never approved is dropped
118
+ after `DCR_PENDING_TTL_HOURS` (24), an approved one nobody has used after
119
+ `DCR_INACTIVE_DAYS` (90) along with its approval and refresh tokens, and the
120
+ store holds at most `DCR_MAX_CLIENTS` (500). Reaching the ceiling evicts the
121
+ oldest never-approved registrations; when every one of them has been approved
122
+ the newcomer is refused instead, so registering repeatedly cannot push a
123
+ working connector out. Opening the authorization page counts as use, so a slow
124
+ login is not cut short. The sweep runs at startup and every fifteen minutes,
125
+ and an existing state file is given a fresh clock rather than being read as
126
+ idle since the day each client registered. Client ID Metadata Document clients
127
+ are unaffected — they are never stored.
128
+
129
+ - **Clients can manage their own registration (RFC 7592).** The registration
130
+ response now carries `registration_access_token` and
131
+ `registration_client_uri`, and `GET`, `PUT` and `DELETE` on
132
+ `/register/<client_id>` let a client read, change or remove what it
133
+ registered. Only a hash of the token is stored, so it is shown exactly once.
134
+ `DELETE` takes the approval and every refresh token with it. Changing the
135
+ redirect URIs through `PUT` withdraws the approval — consent was given for a
136
+ destination and does not transfer to a new one — while changing a name or a
137
+ logo leaves it in place. A wrong token and an unknown `client_id` get the same
138
+ answer, so the endpoint cannot be used to enumerate registrations. None of
139
+ this comes from the SDK, whose registration router accepts `POST` and nothing
140
+ else.
141
+
142
+ - `mcp-hub-admin clients delete <client-id>` removes a registration outright,
143
+ where `clients revoke` withdraws access but keeps it, and
144
+ `mcp-hub-admin clients prune [--dry-run]` applies the lifecycle rules on
145
+ demand instead of waiting for the next sweep.
146
+
147
+ - `mcp-hub-admin clients list` now also lists clients that were approved
148
+ without ever being registered, and says which mechanism each one came in
149
+ through. A metadata-document client leaves no registration behind, so its
150
+ approval is the whole record; `clients revoke` works on it either way.
151
+
152
+ - **A `demo/` directory you can run without owning anything.**
153
+ `docker compose up -d` brings up a hub with three fake MCP servers —
154
+ weather, tickets and a small index of these docs — and
155
+ [the page that goes with it](https://mcp-hub.ni-c.de/guide/demo) shows how to
156
+ point the MCP Inspector or MCPJam at it. The servers answer from tables
157
+ compiled into them: no network, no filesystem, no stored state, so the same
158
+ call gives the same answer and nothing a visitor does outlasts the request.
159
+ `demo/token.sh` mints the API tokens. It exists because the first question
160
+ about a gateway is what it looks like from the client side, and until now the
161
+ only way to find out was to deploy one.
162
+
163
+ ### Changed
164
+
165
+ - The README now carries the same eight badges, in the same order, as every other
166
+ MCP server in this family, all of them reading from npm rather than hard-coded;
167
+ the opening follows one shape; and the standalone "Full documentation" line is
168
+ gone, because the docs badge three lines above it points at the same page.
169
+
170
+ - The authorization-server metadata advertises
171
+ `client_id_metadata_document_supported`, and `private_key_jwt` alongside
172
+ `client_secret_post` and `none` in `token_endpoint_auth_methods_supported`.
173
+ The enriched document is served at the root path, the RFC 8414 path-inserted
174
+ form and the OpenID Connect discovery alias alike.
175
+
176
+ ### Security
177
+
178
+ - Metadata documents are fetched from a URL an unauthenticated caller chose, so
179
+ the request is treated as hostile: `https` only, redirects never followed,
180
+ private, loopback, link-local and CGNAT addresses refused after DNS
181
+ resolution, a 5 kB cap enforced while reading, a 5-second timeout and a JSON
182
+ content type required. Documents carrying a `client_secret` or declaring a
183
+ symmetric authentication method are refused outright. Concurrent lookups of
184
+ one URL collapse into a single request, rejections are remembered for 30
185
+ seconds and the cache is bounded, so a `client_id` cannot be used to point the
186
+ hub at a third party. Every rejection answers a bare `invalid_client`; the
187
+ reason goes to the log only, so the admission policy cannot be mapped by
188
+ probing.
189
+
190
+ - **A client declaring `private_key_jwt` must present an assertion.** Client
191
+ authentication is driven by the stored record, and a metadata-document client
192
+ never has a `client_secret` — so a token request that simply omitted
193
+ `client_assertion` was treated as a public client and accepted on its
194
+ `client_id` alone. A leaked refresh token or authorization code was therefore
195
+ redeemable without the private key that exists to prevent exactly that. The
196
+ assertion is now required whenever the document declares it.
197
+
198
+ - **The connection is pinned to the address that was checked.** The SSRF guard
199
+ resolved the hostname and then handed the name to `fetch`, which resolved it
200
+ again; a zone answering differently the second time could move the request
201
+ onto an internal address or a cloud metadata endpoint. The vetted address is
202
+ now what the socket connects to, with the certificate still validated against
203
+ the hostname. The IPv6 forms that carry an IPv4 address (NAT64 `64:ff9b::/96`,
204
+ 6to4 `2002::/16`) and several reserved IPv4 ranges are refused as well.
205
+
206
+ - **The `jwks_uri` fetch is capped at 64 kB.** It inherited the redirect,
207
+ timeout and address guards but not the size limit, and the JWKS is parsed
208
+ whole — an unauthenticated token request naming a document with a hostile
209
+ `jwks_uri` could push an unbounded body into the heap and take the hub, and
210
+ every MCP server it supervises, down with it. The cache of remote key sets is
211
+ now bounded too; entries were created before the signature was checked.
212
+
213
+ - **Untrusted values can no longer forge a log record.** A `client_id` may
214
+ contain newlines — the URL parser strips them, so the value passed every
215
+ structural check while the raw string reached the log, where each line is
216
+ given a valid timestamp. A forged `mcp-hub: authentication failure from …`
217
+ line matches the fail2ban filter this project ships, which made it possible
218
+ to have any address banned by sending unauthenticated requests. Client-chosen
219
+ values are escaped and capped at the point they enter a log line.
220
+
221
+ - **Redirect URIs are held to one rule for both registration mechanisms.**
222
+ Dynamic registration accepted anything outside the SDK's three-scheme
223
+ denylist, including a plaintext `http://` callback on a remote host, which
224
+ delivers the authorization code in the clear. Registration now requires
225
+ `https`, a loopback address, or an application-specific scheme for native
226
+ clients, and answers `400 invalid_client_metadata` otherwise.
227
+
228
+ - Self-declared client names are reduced to a single short line before they are
229
+ stored or shown. They were escaped but unbounded, so a name of several hundred
230
+ characters could push the redirect target and the loopback warning off the
231
+ consent page.
232
+
233
+ - `/health` is authenticated and bound to the `hub` resource; only `/livez` is
234
+ public. A stale comment claimed the opposite, which would have justified
235
+ exposing the deployment topology.
236
+
237
+ - Revocation markers are dropped once they are older than the longest-lived
238
+ refresh token they could reject. They were the one part of the state file
239
+ that only ever grew.
240
+
10
241
  ## [0.9.2] - 2026-08-24
11
242
 
12
243
  ### Fixed
package/README.md CHANGED
@@ -1,20 +1,23 @@
1
1
  # mcp-hub
2
2
 
3
- [![CI](https://github.com/ni-c/mcp-hub/actions/workflows/ci.yml/badge.svg)](https://github.com/ni-c/mcp-hub/actions/workflows/ci.yml)
3
+ [![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)
4
4
  [![npm version](https://img.shields.io/npm/v/%40ni-c%2Fmcp-hub)](https://www.npmjs.com/package/@ni-c/mcp-hub)
5
5
  [![npm downloads](https://img.shields.io/npm/dm/%40ni-c%2Fmcp-hub)](https://www.npmjs.com/package/@ni-c/mcp-hub)
6
- [![Container](https://img.shields.io/badge/ghcr.io-ni--c%2Fmcp--hub-2496ED?logo=docker&logoColor=white)](https://github.com/ni-c/mcp-hub/pkgs/container/mcp-hub)
7
- [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
8
- [![Docs](https://img.shields.io/badge/docs-mcp--hub.ni--c.de-4f46e5)](https://mcp-hub.ni-c.de)
9
-
10
- 📖 **Full documentation: <https://mcp-hub.ni-c.de>**
11
-
12
- Serve many stdio MCP servers from **one container**, published over HTTPS for
13
- ChatGPT connectors, Claude (Web and Code), Mistral Le Chat, Cursor, LibreChat
14
- and any other Streamable-HTTP MCP client — with a built-in OAuth 2.1 login
15
- protected by a single password, plus long-lived API tokens for clients that
16
- cannot do OAuth (OpenAI Responses API, xAI API, Gemini API). Per-client
17
- recipes: [client compatibility](https://mcp-hub.ni-c.de/guide/client-compatibility).
6
+ [![node](https://img.shields.io/node/v/%40ni-c%2Fmcp-hub)](https://nodejs.org)
7
+ [![license](https://img.shields.io/npm/l/%40ni-c%2Fmcp-hub)](LICENSE)
8
+ [![container](https://img.shields.io/badge/ghcr.io-ni--c%2Fmcp--hub-blue)](https://github.com/ni-c/mcp-hub/pkgs/container/mcp-hub)
9
+ [![docs](https://img.shields.io/badge/docs-mcp--hub.ni--c.de-informational)](https://mcp-hub.ni-c.de)
10
+ [![sponsor](https://img.shields.io/badge/sponsor-ni--c-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/ni-c)
11
+
12
+ A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) gateway: it serves
13
+ many stdio MCP servers from **one container**, published over HTTPS.
14
+
15
+ Lets MCP clients that cannot spawn a local process ChatGPT connectors, Claude on
16
+ the Web and in Code, Mistral Le Chat, Cursor, LibreChat and any other
17
+ Streamable-HTTP client — reach every server behind it, with a built-in OAuth 2.1
18
+ login protected by a single password, plus long-lived API tokens for clients that
19
+ cannot do OAuth (OpenAI Responses API, xAI API, Gemini API). Per-client recipes:
20
+ [client compatibility](https://mcp-hub.ni-c.de/guide/client-compatibility).
18
21
 
19
22
  <!-- <picture> is resolved against the colour scheme of the page showing it, so GitHub
20
23
  picks the variant that matches its own theme toggle. npm strips <picture> and
@@ -28,18 +31,29 @@ recipes: [client compatibility](https://mcp-hub.ni-c.de/guide/client-compatibili
28
31
 
29
32
  ![Demo: config in, hub up, servers reachable through one endpoint](https://mcp-hub.ni-c.de/demo.gif)
30
33
 
34
+ **Want to poke at it first?** [`demo/`](demo/) is a throwaway hub with three
35
+ fake servers — `docker compose up -d`, then point the
36
+ [MCP Inspector](https://mcp-hub.ni-c.de/guide/demo#mcp-inspector) or
37
+ [MCPJam](https://mcp-hub.ni-c.de/guide/demo#mcpjam) at it. Nothing to
38
+ configure, nothing to clean up but a volume.
39
+
31
40
  ## Why
32
41
 
33
42
  Wrapping each stdio MCP server in its own auth-proxy container costs a full
34
- image, an OAuth stack, a hostname and a compose stack *per server*. mcp-hub
43
+ image, an OAuth stack, a hostname and a compose stack _per server_. mcp-hub
35
44
  replaces N containers with one process:
36
45
 
37
46
  - **Config is exactly Claude Code's `mcpServers` format** — copy entries 1:1.
38
47
  - **Path-based routing**: `https://host/paperless`, `https://host/homeassistant`, …
39
- - **`/hub` aggregate**: register a *single* connector and reach every server
48
+ - **`/hub` aggregate**: register a _single_ connector and reach every server
40
49
  through 6 meta-tools (`list_servers`, `list_tools`, `get_tool_schema`,
41
50
  `call_tool`, `wake_server`, `sleep_server`) without flooding the model
42
51
  context with N×tools schemas.
52
+ - **Per-server tool filtering**: `allowTools` / `denyTools` on any server decide
53
+ which of its tools the hub exposes — exact names or `list_*` prefixes. A
54
+ filtered tool is hidden from `tools/list` _and_ refused if a client calls it
55
+ anyway, before the server is even woken, so a client holding a stale schema
56
+ cannot reach it.
43
57
  - **Also without HTTP**: `mcp-hub --stdio` serves that same aggregate on
44
58
  stdin/stdout for clients that can only spawn a local process (Claude Desktop,
45
59
  Codex, …) — same `mcp.json`, no TLS, no reverse proxy, no login. Auth exists
@@ -48,6 +62,20 @@ replaces N containers with one process:
48
62
  after 60 idle minutes, answering `initialize`/`tools/list` from a persistent
49
63
  snapshot meanwhile — a dozen servers cost only the memory of the ones in
50
64
  use. `keepAlive: true` exempts a server, `IDLE_TIMEOUT_MINUTES=0` the hub.
65
+ - **CIMD-first OAuth 2.1**: clients identify themselves with a [Client ID
66
+ Metadata Document](https://mcp-hub.ni-c.de/guide/client-registration) — the
67
+ registration-free path the MCP spec now prefers — including `private_key_jwt`
68
+ against the keys in their own document (metadata-document clients only). RFC
69
+ 7591 dynamic registration stays advertised beside it for older clients,
70
+ `mcp-hub-admin clients add` issues credentials by hand for anything that can
71
+ do neither, and `CLIENT_REGISTRATION` turns either mechanism off.
72
+ - **OAuth outwards, too**: a remote server that speaks OAuth gets an `oauth`
73
+ block instead of a static header. The hub registers itself — with credentials
74
+ the upstream issued, via RFC 7591, or with its own client metadata document —
75
+ then obtains and refreshes the token. `client_credentials` upstreams need no
76
+ attention at all; where a person must sign in, `mcp-hub-admin upstream login`
77
+ prints one URL. An upstream that needs re-authorizing shows up as one server
78
+ `unauthorized`, not as a confusing 401 in your client.
51
79
  - **Supervision**: children are pinged and restarted with exponential backoff
52
80
  when they die. A down server answers 503, not silence; a crash-looping
53
81
  server nobody uses is parked instead of restarted forever.
@@ -81,6 +109,11 @@ version in your image; do not download mutable packages at runtime:
81
109
  "headers": { "Authorization": "Bearer ${HA_TOKEN}" }
82
110
  },
83
111
  "private-thing": { "command": "some-mcp", "args": [], "hub": false },
112
+ "paperless-readonly": {
113
+ "command": "paperless-mcp",
114
+ "allowTools": ["search_*", "get_document"],
115
+ "denyTools": ["delete_document"]
116
+ },
84
117
  "untrusted": {
85
118
  "type": "docker",
86
119
  "image": "ghcr.io/example/untrusted-mcp@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
@@ -95,13 +128,16 @@ Stdio servers (`command`/`args`/`env`) are spawned as supervised child
95
128
  processes. Remote servers (`type: "http"` or `"sse"` with `url` and optional
96
129
  `headers`) are connected as MCP clients with the configured headers injected
97
130
  on every request — the same supervision (ping, backoff reconnect, hot reload)
98
- applies. Upstreams that require their own *interactive* OAuth cannot be
99
- configured with static headers; bridge those with an
100
- [`mcp-remote`](https://github.com/geelen/mcp-remote) stdio entry and persist
101
- its token cache (`MCP_REMOTE_CONFIG_DIR`) under `/data`.
131
+ applies. An upstream that speaks OAuth gets an `oauth` block instead of a
132
+ header: the hub registers itself (statically, via RFC 7591 or via a client
133
+ metadata document), obtains the token and refreshes it, with one browser visit
134
+ started from the admin CLI where the grant needs a person.
102
135
  `"hub": false` hides a server from the `/hub` aggregate; its own path keeps
103
- working. Reserved names: `mcp`, `hub`, `authorize`, `token`, `register`,
104
- `login`, `consent`, `health`, `livez`, `revoke`.
136
+ working. `allowTools` / `denyTools` cut finer and apply to every kind of
137
+ server: a filtered tool is absent from both `tools/list` and `/hub`, and is
138
+ refused if called anyway — before the server is woken.
139
+ Reserved names: `mcp`, `hub`, `authorize`, `token`, `register`,
140
+ `login`, `consent`, `health`, `livez`, `revoke`, `upstream`, `.well-known`.
105
141
 
106
142
  All stdio children share the hub's Unix user and can read its mounted files.
107
143
  Only install fully trusted stdio servers. A server with a different trust level
@@ -126,7 +162,7 @@ and [SECURITY.md](SECURITY.md).
126
162
  For a custom image, pin every package to an exact version:
127
163
 
128
164
  ```dockerfile
129
- FROM ghcr.io/ni-c/mcp-hub:0.6.0 # pin @sha256:<digest> in production
165
+ FROM ghcr.io/ni-c/mcp-hub:0.10.0 # pin @sha256:<digest> in production
130
166
  USER root
131
167
  RUN npm install -g your-mcp-package@1.2.3
132
168
  USER node
@@ -134,24 +170,38 @@ USER node
134
170
 
135
171
  ### Environment
136
172
 
137
- | Variable | Required | Description |
138
- |---|---|---|
139
- | `EXTERNAL_URL` | yes | Public base URL, e.g. `https://mcp.example.net` (no path) |
140
- | `PASSWORD_HASH` | one of | bcrypt hash of the login password (`htpasswd -bnBC 10 "" 'pw' \| tr -d ':\n'`) |
141
- | `PASSWORD` | one of | plain-text alternative to `PASSWORD_HASH` |
142
- | `TRUSTED_PROXIES` | no | comma-separated IPs/CIDRs allowed to set `X-Forwarded-*` (see below) |
143
- | `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 |
144
- | `DEFAULT_RESOURCE` | no | server name (or `hub`) to bind tokens to when a client sends no `resource` parameter; unset → such requests are refused |
145
- | `MCP_BODY_LIMIT` | no | authenticated MCP JSON body limit, default `1mb` |
146
- | `MCP_REQUESTS_PER_MINUTE` | no | limit per OAuth client, default `120` |
147
- | `MCP_MAX_CONCURRENT_REQUESTS` | no | in-flight request limit per OAuth client, default `4` |
148
- | `MCP_MAX_CONCURRENT_STREAMS` | no | open SSE listening streams per OAuth client — one per connected session, default `32` |
149
- | `HTTP_HEADERS_TIMEOUT_MS` | no | Node HTTP header timeout, default `10000` |
150
- | `HTTP_REQUEST_TIMEOUT_MS` | no | complete request timeout, default `310000` (slightly above the tool-call timeout) |
151
- | `PORT` | no | listen port (default 80 in the image, 3000 outside) |
152
- | `CONFIG_PATH` | no | default `/config/mcp.json` |
153
- | `DATA_PATH` | no | default `/data` |
154
- | `LOG_FILE` | no | additionally mirror all log output into this file, e.g. `/data/mcp-hub.log` (see below) |
173
+ | Variable | Required | Description |
174
+ | ------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
175
+ | `EXTERNAL_URL` | yes | Public base URL, e.g. `https://mcp.example.net` (no path) |
176
+ | `PASSWORD_HASH` | one of | bcrypt hash of the login password (`htpasswd -bnBC 10 "" 'pw' \| tr -d ':\n'`) |
177
+ | `PASSWORD` | one of | plain-text alternative to `PASSWORD_HASH` |
178
+ | `TRUSTED_PROXIES` | no | comma-separated IPs/CIDRs allowed to set `X-Forwarded-*` (see below) |
179
+ | `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 |
180
+ | `DEFAULT_RESOURCE` | no | server name (or `hub`) to bind tokens to when a client sends no `resource` parameter; unset → such requests are refused |
181
+ | `MCP_BODY_LIMIT` | no | authenticated MCP JSON body limit, default `1mb` |
182
+ | `MCP_REQUESTS_PER_MINUTE` | no | limit per OAuth client, default `120` |
183
+ | `MCP_MAX_CONCURRENT_REQUESTS` | no | in-flight request limit per OAuth client, default `4` |
184
+ | `MCP_MAX_CONCURRENT_STREAMS` | no | open SSE listening streams per OAuth client — one per connected session, default `32` |
185
+ | `HTTP_HEADERS_TIMEOUT_MS` | no | Node HTTP header timeout, default `10000` |
186
+ | `HTTP_REQUEST_TIMEOUT_MS` | no | complete request timeout, default `310000` (slightly above the tool-call timeout) |
187
+ | `PORT` | no | listen port (default 80 in the image, 3000 outside) |
188
+ | `CONFIG_PATH` | no | default `/config/mcp.json` |
189
+ | `DATA_PATH` | no | default `/data` |
190
+ | `LOG_FILE` | no | additionally mirror all log output into this file, e.g. `/data/mcp-hub.log` (see below) |
191
+ | `CLIENT_REGISTRATION` | no | which mechanisms a client may use for a `client_id`: `cimd`, `dcr` or both (default) |
192
+ | `CIMD_ALLOWED_ORIGINS` | no | bare https origins whose metadata documents are accepted; unset → any |
193
+ | `CIMD_ALLOW_PRIVATE_ADDRESSES` | no | local development only; relaxes the SSRF guard, warns on every start |
194
+ | `DCR_MAX_CLIENTS` | no | ceiling on stored dynamic registrations, default `500` |
195
+ | `DCR_PENDING_TTL_HOURS` | no | how long a never-approved registration is kept, default `24` |
196
+ | `DCR_INACTIVE_DAYS` | no | how long an unused approved registration is kept, default `90` |
197
+ | `IDLE_TIMEOUT_MINUTES` | no | idle minutes before an on-demand server sleeps, default `60`; `0` disables it |
198
+ | `TOOL_CACHE_PATH` | no | snapshots of sleeping servers, default `<DATA_PATH>/tool-cache.json` |
199
+ | `MCP_CALL_TIMEOUT_MS` | no | deadline for one forwarded tool call, default `300000` |
200
+ | `MCP_RESET_TIMEOUT_ON_PROGRESS` | no | let progress notifications extend that deadline, default `false` |
201
+ | `DOCKER_HOST` | with docker servers | the **policy proxy's** socket; a direct daemon socket fails closed |
202
+
203
+ The full table, including what applies in stdio mode, is in the
204
+ [environment reference](https://mcp-hub.ni-c.de/reference/environment).
155
205
 
156
206
  `/data` holds the Ed25519 JWT key, registered OAuth clients, approvals and
157
207
  refresh tokens. **Mount it as a volume** — recreating it invalidates every
@@ -170,7 +220,7 @@ invalidates those unbound tokens, so every connector authorizes once more.
170
220
 
171
221
  `TRUSTED_PROXIES` decides what `req.ip` is, and therefore what the login rate
172
222
  limiter counts. List **only** your own reverse proxy, and make sure it
173
- *overwrites* `X-Forwarded-For` rather than appending to it — otherwise a
223
+ _overwrites_ `X-Forwarded-For` rather than appending to it — otherwise a
174
224
  client can supply its own address and rotate it to sidestep the per-IP limit.
175
225
  If the variable is unset, every request appears to come from the proxy and
176
226
  per-IP limiting degrades to a single global counter (the hub logs a warning
@@ -185,7 +235,7 @@ Published on every push to `main` and every `vX.Y.Z` release tag, for
185
235
  [package page](https://github.com/ni-c/mcp-hub/pkgs/container/mcp-hub).
186
236
 
187
237
  ```sh
188
- docker pull ghcr.io/ni-c/mcp-hub:0.6.0
238
+ docker pull ghcr.io/ni-c/mcp-hub:0.10.0
189
239
  ```
190
240
 
191
241
  Tags: `latest` (tip of `main`), `X.Y.Z` and `X.Y` (releases), and
@@ -200,7 +250,7 @@ With compose, copy the example and point it at the image instead of building:
200
250
  ```yaml
201
251
  services:
202
252
  mcp-hub:
203
- image: ghcr.io/ni-c/mcp-hub:0.6.0 # replaces `build: .`; pin a digest in production
253
+ image: ghcr.io/ni-c/mcp-hub:0.10.0 # replaces `build: .`; pin a digest in production
204
254
  # ...rest of docker-compose.example.yml unchanged
205
255
  ```
206
256
 
@@ -222,7 +272,7 @@ docker run -d --name mcp-hub \
222
272
  -e TRUSTED_PROXIES="192.168.1.0/24" \
223
273
  -v "$PWD/config:/config:ro" \
224
274
  -v "$PWD/data:/data" \
225
- ghcr.io/ni-c/mcp-hub:0.6.0
275
+ ghcr.io/ni-c/mcp-hub:0.10.0
226
276
  ```
227
277
 
228
278
  Update to a newer image with `docker compose pull && docker compose up -d`
@@ -263,7 +313,7 @@ see [client compatibility](https://mcp-hub.ni-c.de/guide/client-compatibility).
263
313
 
264
314
  Each client is confirmed once. Entering the password approves the client that
265
315
  asked; while a login session is still valid, a client you have not seen before
266
- gets an explicit *Approve / Deny* page instead of a code. Approved clients
316
+ gets an explicit _Approve / Deny_ page instead of a code. Approved clients
267
317
  reconnect silently from then on.
268
318
 
269
319
  List clients or revoke one. The CLI shares `/data` with the running hub and
@@ -273,10 +323,17 @@ live container — a revocation takes effect on the next request:
273
323
  ```sh
274
324
  docker exec mcp-hub node /app/dist/admin.js clients list
275
325
  docker exec mcp-hub node /app/dist/admin.js clients revoke CLIENT_ID
326
+ docker exec mcp-hub node /app/dist/admin.js clients delete CLIENT_ID
327
+ docker exec mcp-hub node /app/dist/admin.js clients prune --dry-run
276
328
  ```
277
329
 
278
330
  Revocation removes the approval and all refresh tokens and immediately rejects
279
331
  already-issued access tokens. The next connection needs explicit approval.
332
+ `delete` goes further and removes the registration itself, and `prune` applies
333
+ the [registration lifecycle rules](https://mcp-hub.ni-c.de/guide/client-registration)
334
+ on demand — registrations that were never approved expire after a day, unused
335
+ ones after 90 days, and a dynamically registered client can also remove its own
336
+ registration through RFC 7592.
280
337
 
281
338
  For clients that cannot do OAuth at all — the OpenAI Responses API, the xAI
282
339
  API, Gemini's `mcp_server` tool, plain-header clients — the same CLI mints
@@ -294,15 +351,18 @@ immediately. Per-client recipes:
294
351
 
295
352
  ## Endpoints
296
353
 
297
- | Path | Auth | Purpose |
298
- |---|---|---|
299
- | `/<name>`, `/<name>/mcp` | Bearer | Streamable HTTP endpoint of one server |
300
- | `/hub` | Bearer | aggregate endpoint with the 4 meta-tools |
301
- | `/livez` | none | minimal process liveness (`200`) |
302
- | `/health` | Bearer | per-server status (`200` all up / `503` degraded) |
303
- | `/authorize`, `/token`, `/register`, `/login`, `/consent`, `/revoke` | — | OAuth 2.1 + DCR |
304
- | `/.well-known/oauth-authorization-server[/…]` | none | RFC 8414 metadata |
305
- | `/.well-known/oauth-protected-resource[/…]` | none | RFC 9728 metadata (path-scoped) |
354
+ | Path | Auth | Purpose |
355
+ | -------------------------------------------------------------------- | -------------------------- | ----------------------------------------------------------------- |
356
+ | `/<name>`, `/<name>/mcp` | Bearer | Streamable HTTP endpoint of one server |
357
+ | `/hub` | Bearer | aggregate endpoint with the 6 meta-tools |
358
+ | `/livez` | none | minimal process liveness (`200`) |
359
+ | `/health` | Bearer | per-server status (`200` all up / `503` degraded) |
360
+ | `/authorize`, `/token`, `/register`, `/login`, `/consent`, `/revoke` | — | OAuth 2.1 · CIMD + DCR |
361
+ | `/register/<client_id>` | registration token | RFC 7592: a client reads, changes or removes its own registration |
362
+ | `/upstream/callback` | signed state + hub session | where an upstream returns after `upstream login` |
363
+ | `/.well-known/mcp-hub-client/<id>.json` | none | the hub's own client metadata document, one per `cimd` upstream |
364
+ | `/.well-known/oauth-authorization-server[/…]` | none | RFC 8414 metadata |
365
+ | `/.well-known/oauth-protected-resource[/…]` | none | RFC 9728 metadata (path-scoped) |
306
366
 
307
367
  ## Notes & limitations
308
368