@ni-c/mcp-hub 0.10.0 → 0.11.1

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 (87) hide show
  1. package/CHANGELOG.md +426 -0
  2. package/README.md +79 -12
  3. package/dist/admin.js +1 -1
  4. package/dist/auth/api-tokens.js +27 -0
  5. package/dist/auth/headers.js +22 -1
  6. package/dist/auth/oidc/adapter.js +135 -0
  7. package/dist/auth/oidc/interactions.js +187 -0
  8. package/dist/auth/oidc/mount.js +144 -0
  9. package/dist/auth/oidc/provider.js +440 -0
  10. package/dist/auth/oidc/quirks.js +234 -0
  11. package/dist/auth/oidc/verifier.js +121 -0
  12. package/dist/auth/protected-resource.js +41 -0
  13. package/dist/auth/rate-limit.js +115 -2
  14. package/dist/auth/redirect-uri.js +33 -1
  15. package/dist/auth/registration.js +1 -1
  16. package/dist/auth/session.js +43 -0
  17. package/dist/auth/store.js +160 -1
  18. package/dist/config.js +55 -2
  19. package/dist/docker-proxy/policy.js +1 -0
  20. package/dist/docker-proxy/server.js +1 -0
  21. package/dist/elicitation.js +0 -0
  22. package/dist/forward.js +0 -0
  23. package/dist/hub.js +309 -29
  24. package/dist/index.js +103 -21
  25. package/dist/limits.js +13 -1
  26. package/dist/mcp-limits.js +14 -2
  27. package/dist/proxy.js +246 -40
  28. package/dist/stdio.js +71 -6
  29. package/dist/subscriptions.js +236 -0
  30. package/dist/supervisor.js +322 -22
  31. package/dist/timings.js +61 -0
  32. package/dist/tool-filter.js +1 -0
  33. package/dist/transports/stream.js +33 -20
  34. package/dist/upstream/auth.js +1 -1
  35. package/dist/upstream/routes.js +3 -2
  36. package/package.json +20 -6
  37. package/dist/admin.js.map +0 -1
  38. package/dist/auth/address.js.map +0 -1
  39. package/dist/auth/cimd.js.map +0 -1
  40. package/dist/auth/consent-page.js.map +0 -1
  41. package/dist/auth/headers.js.map +0 -1
  42. package/dist/auth/login-page.js.map +0 -1
  43. package/dist/auth/page.js.map +0 -1
  44. package/dist/auth/pinned-fetch.js.map +0 -1
  45. package/dist/auth/private-key-jwt.js +0 -213
  46. package/dist/auth/private-key-jwt.js.map +0 -1
  47. package/dist/auth/provider.js +0 -437
  48. package/dist/auth/provider.js.map +0 -1
  49. package/dist/auth/rate-limit.js.map +0 -1
  50. package/dist/auth/redirect-uri.js.map +0 -1
  51. package/dist/auth/registration.js.map +0 -1
  52. package/dist/auth/resource.js.map +0 -1
  53. package/dist/auth/routes.js +0 -249
  54. package/dist/auth/routes.js.map +0 -1
  55. package/dist/auth/signed-token.js.map +0 -1
  56. package/dist/auth/store.js.map +0 -1
  57. package/dist/auth/text.js.map +0 -1
  58. package/dist/config.js.map +0 -1
  59. package/dist/docker-proxy/index.js.map +0 -1
  60. package/dist/docker-proxy/policy.js.map +0 -1
  61. package/dist/docker-proxy/secrets-watcher.js.map +0 -1
  62. package/dist/docker-proxy/secrets.js.map +0 -1
  63. package/dist/docker-proxy/server.js.map +0 -1
  64. package/dist/health.js.map +0 -1
  65. package/dist/hub.js.map +0 -1
  66. package/dist/index.js.map +0 -1
  67. package/dist/limits.js.map +0 -1
  68. package/dist/logfile.js.map +0 -1
  69. package/dist/main-module.js.map +0 -1
  70. package/dist/mcp-limits.js.map +0 -1
  71. package/dist/mount-check.js.map +0 -1
  72. package/dist/proxy.js.map +0 -1
  73. package/dist/sandbox/container-spec.js.map +0 -1
  74. package/dist/sandbox/docker-client.js.map +0 -1
  75. package/dist/sandbox/policy-protocol.js.map +0 -1
  76. package/dist/stdio.js.map +0 -1
  77. package/dist/supervisor.js.map +0 -1
  78. package/dist/tool-cache.js.map +0 -1
  79. package/dist/tool-filter.js.map +0 -1
  80. package/dist/transports/docker.js.map +0 -1
  81. package/dist/transports/socket.js.map +0 -1
  82. package/dist/transports/stream.js.map +0 -1
  83. package/dist/upstream/auth.js.map +0 -1
  84. package/dist/upstream/login.js.map +0 -1
  85. package/dist/upstream/provider.js.map +0 -1
  86. package/dist/upstream/routes.js.map +0 -1
  87. package/dist/version.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,432 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  <!-- #region changelog -->
9
9
 
10
+ ## [0.11.1] - 2026-09-06
11
+
12
+ ### Security
13
+
14
+ - **The published image shipped a vulnerable `libssh2`, and no amount of
15
+ rescanning was going to change that.** `libssh2-1 1.10.0-3+b1` — pulled in as
16
+ a dependency of `git`, which the image installs so servers can be fetched
17
+ straight from a repository — is affected by CVE-2026-7598 and CVE-2026-58050,
18
+ both HIGH, both fixed in `bookworm-security` as `1.10.0-3+deb12u1` before the
19
+ scanner ever reported them. The fix could not reach the image because the apt
20
+ layer was never rebuilt: the base digest is pinned and the apt command is a
21
+ constant, so the layer cache answered every build with the packages installed
22
+ the day it was first built. The image scanned green one afternoon and red
23
+ three hours later from the same cached layer, and a workflow rerun reproduced
24
+ the finding exactly.
25
+
26
+ Both Dockerfiles now take an `APT_SECURITY_EPOCH` build argument that is
27
+ interpolated into the apt command itself, and CI passes the current UTC date.
28
+ The layer therefore expires once a day rather than never, while builds within
29
+ a day stay cached. The argument has to appear *in* the command — BuildKit keys
30
+ a `RUN` on its expanded command line, and an ARG that is merely declared
31
+ invalidates nothing. In the hub image the npm replacement step moved above
32
+ that boundary: it is the expensive one, it rots with its pins rather than with
33
+ time, and it needs neither apt nor the system CA store.
34
+
35
+ This is the second time the same cache has held back a Debian security fix
36
+ (CVE-2026-56408 in `libexpat1` was the first), which is why the remedy is a
37
+ mechanism rather than another rebuild.
38
+
39
+ ### Changed
40
+
41
+ - The npm tarball no longer carries the `.js.map` source maps — 61 files and
42
+ 360 kB unpacked, against 540 kB of actual code. `sourceMap` stays on and the
43
+ maps are still built: the integration tests and CI run `dist/index.js` out of
44
+ a checkout, where `src/` sits right next to it and a stack trace resolves
45
+ through them. In an installed package it cannot — the sources those maps point
46
+ at are not part of it.
47
+
48
+ ## [0.11.0] - 2026-09-03
49
+
50
+ ### Added
51
+
52
+ - **The meta-tools answer in both channels.** Five of the six now declare an
53
+ `outputSchema` and return the same object as `structuredContent` as well as
54
+ the JSON text block they always returned. A client that wants to *use* the
55
+ answer no longer has to parse a string and hope; one that wants to read it
56
+ still gets the text, because the SDK does not synthesize a text block for an
57
+ object-shaped value and dropping it would have left older clients with
58
+ nothing.
59
+
60
+ `list_servers` and `list_tools` therefore answer with `{"servers": […]}` and
61
+ `{"tools": […]}` rather than a bare array. That is the breaking part of this
62
+ change, and it is not cosmetic: the 2025 wire cannot carry a non-object
63
+ `structuredContent`, so the SDK wraps one in `{"result": …}` — and an
64
+ array-rooted answer would let a client tell from the payload which protocol
65
+ revision it had been given. The hub's promise is that it cannot. There is a
66
+ test comparing both eras field for field.
67
+
68
+ `call_tool` is the one without a schema. It returns the child's own result,
69
+ and a schema of the hub's would only be honest if the hub wrapped somebody
70
+ else's payload — which would break the passthrough the era matrix pins across
71
+ all four client/child combinations.
72
+
73
+ - **`get_tool_schema` hands on the child's `outputSchema`.** This is the gap the
74
+ rest of the work uncovered. `call_tool` has always returned a child's
75
+ `structuredContent` verbatim, but the schema to validate it against was
76
+ dropped on the way out of `/hub`, so an aggregate client received structured
77
+ data it had no way to check — while `hub-tools.md` claimed structured content
78
+ "passes through". Half of it did.
79
+
80
+ `list_tools` marks such a tool with `hasOutputSchema: true` rather than
81
+ inlining the schema, so a large server's list stays a list. Both follow the
82
+ rule `annotations` already followed: the child's own document, verbatim, and
83
+ the key absent when it declared none.
84
+
85
+ - **`list_tools` and `get_tool_schema` carry a child's tool annotations
86
+ through.** Over `/hub` a client never sees the child's own `tools/list`, so
87
+ those two answers are the only place it can learn that one of two similarly
88
+ named tools deletes and the other does not. They came back as name plus
89
+ description, which left every tool looking identical at exactly the moment a
90
+ model decides which to call. The proxy endpoint `/<name>/mcp` was already
91
+ correct; there is a test for it now too, because "already correct" is a
92
+ property that stops being true silently.
93
+
94
+ Verbatim, not summarised into a marker of the hub's own. The specification
95
+ says a client "MUST consider tool annotations to be untrusted unless they come
96
+ from trusted servers", and the hub is in no position to vouch for a child it
97
+ merely forwards to — a derived `kind` would have been the hub's claim about
98
+ somebody else's server. A child that declared nothing arrives with no
99
+ `annotations` key at all; an empty object would read as all four defaults,
100
+ which is a claim it did not make.
101
+
102
+ - **The six meta-tools annotate themselves**, which they never did. The
103
+ specification gives `destructiveHint` and `openWorldHint` a default of `true`,
104
+ so silence declared `list_servers` a destructive tool in an open world.
105
+ `call_tool` is the one where that really is the answer: whatever the named
106
+ tool does, `call_tool` does.
107
+
108
+ - **The hub speaks both MCP revisions on every endpoint.** `2026-07-28` and
109
+ `2025-11-25`, on `/hub`, on `/<name>/mcp` and over `--stdio`; the client
110
+ picks during its opening exchange and cannot tell from the answers which one
111
+ it got. Which traffic is carried on which revision is a
112
+ [matrix](https://mcp-hub.ni-c.de/reference/standards#what-is-carried-per-revision)
113
+ now, with a test behind every row, because this project has twice announced
114
+ something it did not deliver.
115
+
116
+ The 2025 path is untouched: it is served by the same transport that always
117
+ served it, so a `GET` still opens a stream and a `DELETE` still answers 200
118
+ rather than the 405 the modern handler's own fallback would give. claude.ai
119
+ opens that stream on every reconnect.
120
+
121
+ - **Change notifications travel, on both sides.** A `2026-07-28` client opens a
122
+ `subscriptions/listen` stream and names what it wants — tool, prompt and
123
+ resource list changes, or specific resource URIs — and the hub delivers.
124
+ Upstream it subscribes to each child the way that child understands:
125
+ `subscriptions/listen` to a 2026 server, `resources/subscribe` to a 2025 one.
126
+ So a server that has never heard of the newer mechanism still reaches a client
127
+ that speaks nothing else, which is the common case in practice.
128
+ [Details](https://mcp-hub.ni-c.de/guide/subscriptions).
129
+
130
+ This is the second thing the 2026 revision made possible for a stateless
131
+ gateway, and for the same reason as the first: the state is the open HTTP
132
+ response rather than a session table, so a client reconnecting without closing
133
+ anything leaves nothing behind. One handler per route now outlives the
134
+ request, because it owns those streams — it holds the sockets currently open
135
+ and no record of who opened them.
136
+
137
+ The bookkeeping is a lease per stream rather than a reference count per URI.
138
+ A count cannot tell "nobody wants this any more" from "the one leaving wanted
139
+ it too", and gets it wrong in the direction that silently stops delivering to
140
+ the client that stayed.
141
+
142
+ A sleeping [on-demand](https://mcp-hub.ni-c.de/guide/on-demand) server watches
143
+ nothing: subscribing does not wake it — the acknowledgment comes from the
144
+ cached capabilities — and the subscription is re-established when something
145
+ else does, followed by a re-read signal for everything that client was
146
+ watching. What changed during the nap is not reported, only that there is
147
+ reason to look. `subscriptions: "off"` withdraws one server's right to push.
148
+
149
+ - **An end-to-end suite that runs the hub the way it ships.** Three tiers: in
150
+ this process, as `node dist/index.js`, and as the published image through
151
+ `demo/compose.yml`. It is not part of `npm test`, which stays fast and stays
152
+ the pull-request gate; this one runs nightly, on any pull request that touches
153
+ it, and as a gate on release tags. [What it is
154
+ for](https://github.com/ni-c/mcp-hub/blob/main/e2e/README.md).
155
+
156
+ The tiers exist because a class of question cannot be asked from inside the
157
+ process being tested. `src/index.ts`'s startup block — environment parsing,
158
+ the listener, signal handlers — is entered only when the file is the program.
159
+ `mcp-hub-admin` is a *separate program* sharing `/data` with a running hub,
160
+ and a test that called the same `AuthStore` instance proves the hub can read
161
+ its own memory; that mistake shipped once, as a revocation that reported
162
+ success and did nothing. An `uncaughtException` in-process takes the test
163
+ runner down rather than the hub. And uid 1000, a read-only root filesystem,
164
+ the healthcheck and tini cannot be wrong in a bare process at all.
165
+
166
+ The consumer is a scripted agent rather than a model. It discovers through the
167
+ six meta-tools and then builds its arguments *from the schema the hub
168
+ published*, which is the whole point: a schema damaged in transit — truncated,
169
+ budget-clipped, missing a property it declares required — stops working there
170
+ and nowhere else. A model handed a broken schema improvises around it, and
171
+ improvisation is not an assertion.
172
+
173
+ Alongside it: thirteen fixture servers that each misbehave in one specific way
174
+ no off-the-shelf server does, a four-cell client-era × child-era matrix built
175
+ on one catalogue registered twice so a difference can only be the hub's, raw
176
+ `fetch` conformance checks that assert an HTTP status and a JSON-RPC code
177
+ together, and a recorder for what real clients put on the wire.
178
+
179
+ - **`src/timings.ts`.** The supervisor's ping interval, wake timeout, idle
180
+ sweep and backoff curve read the environment, the same way `mcp-limits.ts`
181
+ already did for the call deadline. `IDLE_TIMEOUT_MS` is the sub-minute sibling
182
+ of `IDLE_TIMEOUT_MINUTES`.
183
+
184
+ Defaults are unchanged, so no deployment behaves differently. What changes is
185
+ that the behaviour becomes observable: at the shipped numbers, watching a
186
+ server fall asleep costs a minute and the five-minute backoff ceiling cannot
187
+ be reached at all. Four minutes of a test suite spent asleep is four minutes
188
+ somebody eventually deletes.
189
+
190
+ - **`describe_connection`, behind `MCP_DIAGNOSTICS`.** A seventh meta-tool that
191
+ answers the one question a client cannot answer for itself: which protocol
192
+ era this connection is on, whether this request carried an elicitation
193
+ capability, and — for a named server — whether a question from it would
194
+ actually reach the person at the far end.
195
+
196
+ It exists because a server falling back to its two-call token looks identical
197
+ to a server that simply did not ask. From inside the client both are silence,
198
+ and only the hub knows which one it was. The first thing it reported in
199
+ anger: a connector on the `2026-07-28` era that declares no `elicitation`
200
+ capability at all — which the era, unlike the older one, would let it do.
201
+
202
+ Off by default. Not for safety — it reports only what the caller's own request
203
+ already carried, and nothing about any other client — but because every tool a
204
+ client can see costs context in every conversation it has, and "six meta-tools
205
+ instead of N×tools" is the argument for the aggregate. With the switch on,
206
+ `/hub` and `--stdio` serve seven.
207
+
208
+ - **One log line when a question is dropped.** When the hub declines to carry a
209
+ child's elicitation it now says so once per client, server and reason, instead
210
+ of leaving the only trace inside a tool result that one caller sees. The
211
+ decision itself moved into a single function that both the forwarding path and
212
+ the new tool call, so the explanation cannot drift from the behaviour.
213
+
214
+ ### Fixed
215
+
216
+ - **The meta-tools no longer advertise an empty schema where they mean
217
+ "anything".** Five fields carry a document the hub does not own — a child's
218
+ `annotations` on `list_tools` and `get_tool_schema`, the two schemas on
219
+ `get_tool_schema`, and the `arguments` of `call_tool` — and zod writes that
220
+ as `"additionalProperties": {}`. An empty schema is legal and means exactly
221
+ what `true` means, but it is the spelling some MCP clients refuse or
222
+ mishandle, which is the worst kind of bug to own: the tool works against the
223
+ client you tested with and fails against the one you did not.
224
+
225
+ Only the emitted JSON Schema changes. The runtime is still as permissive as
226
+ it has to be, and deliberately so: those fields are validated against the zod
227
+ schema, a child is free to put anything in them, and the SDK turns a refused
228
+ answer into an error result. A test walks every schema the hub advertises and
229
+ fails on any node that constrains nothing, so a future zod release cannot put
230
+ the spelling back without saying so.
231
+
232
+ - **A non-object output schema now reaches a 2025 client with its value wrapped
233
+ to match.** The per-server endpoint writes its own `tools/call` handler, and
234
+ such a handler has to run the result through the wire codec itself. It did
235
+ not. The schema half was already being rewritten to `{"result": …}` on the
236
+ way out, so a child whose `outputSchema` describes an array handed a 2025
237
+ client a bare array to validate against a schema saying "object" — correct
238
+ data that looks broken. Both halves are projected now, and a fixture with an
239
+ array-rooted schema pins it on every era pair.
240
+
241
+ The e2e agent, which checks that a result carries everything its schema
242
+ requires, was silently doing nothing on the `/hub` door for the same reason:
243
+ the schema it read from `get_tool_schema` was always `undefined`. It bites
244
+ there now, and it also asserts that the text block and `structuredContent`
245
+ agree.
246
+
247
+ - **Three capabilities the hub announced but did not serve.** `listChanged` for
248
+ tools, prompts and resources is now advertised only on the revision that
249
+ carries it, and is true there; `resources.subscribe` likewise, having been
250
+ stripped outright since 0.6.3. `logging` is no longer advertised at all —
251
+ `logging/setLevel` never had a handler, so a client that believed it got a
252
+ `-32601` at call time, and on `2026-07-28` the level is per-request `_meta`
253
+ with no RPC left to implement.
254
+
255
+ A 2025 client is now told none of the three. That is a visible change, and the
256
+ honest one: it was never going to receive any of them.
257
+
258
+ - **A `subscriptions/listen` POST no longer occupies an in-flight slot.** It is
259
+ a POST whose response stays open for the life of the subscription, so counted
260
+ as work in progress it held one of `MCP_MAX_CONCURRENT_REQUESTS` (default
261
+ four) the entire time — a handful of subscribed clients would have locked
262
+ every tool call on the hub out with a 429 while nothing was running. It is the
263
+ standing channel by another name and is charged to
264
+ `MCP_MAX_CONCURRENT_STREAMS`, where the 2025 era's `GET` already went.
265
+
266
+ - **Elicitation travels end to end.** A child server that needs to ask the
267
+ person at the far end something — `smtp-mcp` before it sends, `imap-mcp`
268
+ before it expunges a mailbox — now reaches them through the hub instead of
269
+ silently falling back to a weaker check. On `2026-07-28` a question is a
270
+ *result*, not a push: the call ends, the person decides, the client retries
271
+ with the answer. Nothing is held open, so the stateless transport is what
272
+ makes this work rather than what prevented it.
273
+
274
+ The hub adds what follows from the question crossing a trust boundary. It is
275
+ attributed to the server that asked, after the text has been stripped of the
276
+ bidirectional and zero-width characters that could visually undo that line.
277
+ Embedded `sampling/createMessage` and `roots/list` requests are dropped and
278
+ named in the log — relaying them would spend the caller's model budget and
279
+ hand out its workspace layout on a child's say-so. The child's `_meta` is
280
+ removed. The resumption state is signed and bound to the server, the tool,
281
+ the OAuth client and the endpoint, so it cannot be pasted onto another call.
282
+
283
+ The capability is mirrored per request from what the client itself declared,
284
+ and never widened — so it is announced only for a call whose answer has
285
+ somewhere to go. A `2025-11-25` client over HTTP is therefore not offered it
286
+ at all, and the child takes its own fallback, which is the same rule this
287
+ project already applies to `listChanged`.
288
+
289
+ `"passthrough": "off"` on a server withdraws its right to put words in front
290
+ of the user without switching it off; `MCP_ELICITATION=false` is the global
291
+ brake. Four further `MCP_ELICITATION_*` variables bound rounds, lifetime,
292
+ message size and payload size. See
293
+ [Elicitation](https://mcp-hub.ni-c.de/guide/elicitation).
294
+
295
+ - **A question from a server that had gone to sleep was lost.** The hub decided
296
+ whether a child could be asked by reading the protocol era off its client,
297
+ and an on-demand child that is asleep has none — so the first tool call after
298
+ an idle nap silently took the weaker path and the second one worked. The wake
299
+ now happens before the decision.
300
+
301
+ ### Changed
302
+
303
+ - **On MCP SDK 2.0.** The single `@modelcontextprotocol/sdk` package has been
304
+ replaced by the split `@modelcontextprotocol/{core,client,server,node,express}`.
305
+ Behaviour is unchanged by the migration itself: no wire format, endpoint or
306
+ response differs, and deployments need do nothing. Speaking `2026-07-28` as
307
+ well is a separate change, listed under Added above — it is what the
308
+ migration was for.
309
+
310
+ Notably **not** installed is `@modelcontextprotocol/server-legacy`, the frozen
311
+ copy of v1's authorization-server helpers that npm marks deprecated on
312
+ install. Replacing the hand-written OAuth server with `oidc-provider` first is
313
+ what made that possible — this migration only had to touch the MCP wire layer.
314
+
315
+ Two things the mechanical migration would have changed quietly, and did not:
316
+ `tools/list` is still walked one page at a time, because v2's `listTools()`
317
+ aggregates the whole pagination internally and would have bypassed the tool
318
+ count and metadata budgets that bound what a hostile child can make the hub
319
+ hold in memory; and a malformed line on a child's stdio is still reported,
320
+ because v2's read buffer skips unparseable lines in silence.
321
+
322
+ - **The authorization server is now `oidc-provider` instead of ~900 lines of
323
+ hand-written OAuth.** Every endpoint keeps its path, the login and consent
324
+ pages are the same pages, and the discovery document advertises everything it
325
+ advertised before — there is a test that compares it field by field against
326
+ the old one and fails on anything that is not a written-down decision.
327
+
328
+ **This is a clean cut, not a migration: every client re-registers and
329
+ authorizes once more.** Tokens issued by the previous server are refused
330
+ rather than honoured, because a credential nothing can revoke is worse than a
331
+ reconnect. Registrations, approvals and API tokens in `state.json` are
332
+ untouched; only the OAuth artifacts are new.
333
+
334
+ Access tokens are **opaque** rather than JWTs. That is what makes
335
+ `mcp-hub-admin clients revoke` take effect on the next call instead of when
336
+ the token expires: oidc-provider never persists a JWT, so a JWT could not be
337
+ withdrawn at all. Nothing that presents a token has to change.
338
+
339
+ Several things got stricter on the way. Replaying a rotated refresh token now
340
+ revokes the grant's access tokens as well. Client assertions may not be valid
341
+ for longer than five minutes. Nothing an authorization server holds is written
342
+ to `state.json` in a form anyone could present — the file used to keep hashes
343
+ of refresh tokens, and now keeps hashes of everything. A `Host` header can no
344
+ longer influence the URLs in the discovery document.
345
+
346
+ Visible differences, none of which change what is allowed: redirects use
347
+ `303` where they used `302`, `invalid_client` may be answered `401` rather
348
+ than `400` (RFC 6749 §5.2 allows either), a rejected redirect URI is reported
349
+ as `invalid_redirect_uri`, and the login page lives at `/interaction/<id>/`
350
+ instead of being rendered by `/authorize` directly. The discovery document
351
+ gained the OpenID fields oidc-provider always publishes; no ID token is ever
352
+ issued.
353
+
354
+ - **Four more reserved server names: `jwks`, `interaction`, `session` and
355
+ `userinfo`.** They are paths the authorization server answers on, and a server
356
+ configured under one of them would shadow the login flow rather than merely be
357
+ unreachable. A configuration using one of these names is now refused at
358
+ startup with the same message as for `token` or `authorize`.
359
+
360
+ - **Both images now run on Node 24 ("Krypton"), the active LTS line, instead of
361
+ Node 26.** Node 26 is Current until October, and a non-LTS build leaves
362
+ `process.release.lts` unset — which is not cosmetic, because libraries branch
363
+ on it. It is also what the CI matrix already tests against, so the container
364
+ and the test runs no longer sat on different majors.
365
+
366
+ Nothing else changes: npm is still replaced wholesale and its three vulnerable
367
+ vendored packages still overwritten in place, verified against the built
368
+ image.
369
+
370
+ ### Security
371
+
372
+ Five findings from an internal review of the authorization surface, which is the
373
+ half that was rewritten onto oidc-provider in this cycle and had not been looked
374
+ at adversarially since.
375
+
376
+ - **`/token` bounds the body it reads.** It is the one provider path whose
377
+ request stream the hub takes away from the library — `stripPhantomSecret` has
378
+ to read it to strip a presented secret — and it read it without a ceiling.
379
+ `/token` cannot ask who is calling before it reads, because the credentials
380
+ are in the body, so an anonymous 300 MB request took the process from 157 MB
381
+ resident to 1.4 GB, and the per-path budget still allows fifty of them per
382
+ caller per window. The ceiling is oidc-provider's own 56 KiB, so nothing a
383
+ legitimate client sends changes, and an oversized body now gets the same
384
+ answer the library would have given it.
385
+
386
+ - **A public client's record no longer carries a secret.** RFC 7591 makes
387
+ `client_secret_basic` the default when `token_endpoint_auth_method` is
388
+ omitted — which is what Claude and ChatGPT both do — so the authorization
389
+ server minted and persisted a real secret before `clientDefaults` rewrote the
390
+ method to `none`. Two consequences, neither visible from outside:
391
+ `state.json` gained a value someone could present, which is the one thing it
392
+ is careful never to hold; and `stripPhantomSecret` gates on the stored client
393
+ having no secret, so it stopped firing for exactly the clients it exists for.
394
+ A connector that echoed the secret from its registration response got
395
+ `401 invalid_client` — the failure quirk 2 was written to prevent. The
396
+ response still carries a secret, because ChatGPT insists on one; only the
397
+ record is cleaned.
398
+
399
+ - **A hundred wrong passwords no longer lock the operator out.** The global
400
+ failure ceiling refused every address once it was reached, so an attacker
401
+ could close the only administrative way in — from one host, in under a second,
402
+ renewable every fifteen minutes — and the operator holding the correct
403
+ password got `429` from an address that had never touched the form. The
404
+ ceiling now refuses the callers that are guessing, and refuses them on their
405
+ first failure rather than their tenth once the hub is under a distributed
406
+ attempt, so the total number of guesses still collapses. What it no longer
407
+ does is let them spend somebody else's budget.
408
+
409
+ - **Every per-caller budget counts an IPv6 /64, not an address.** A /64 is the
410
+ smallest block one subscriber is handed, and a /56 or /48 is what a
411
+ residential line usually gets, so a limiter keyed on the full address counted
412
+ one host as billions of callers: twenty-five registrations from twenty-five
413
+ addresses in one network spent twenty-five budgets of twenty. IPv4 keeps its
414
+ own address, and an IPv4-mapped form is folded back onto it so the same caller
415
+ is not counted twice for arriving over a dual-stack listener. Log lines are
416
+ unchanged — fail2ban still gets the host that actually connected.
417
+
418
+ - **A `TRUSTED_PROXIES` list that never matches is now said out loud.** It is
419
+ compared against the address the connection came from, and the easiest way to
420
+ get it wrong is the least visible: a proxy configured as
421
+ `proxy_pass http://localhost:…` on a dual-stack host arrives over `::1`,
422
+ which `127.0.0.1` does not cover. Every forwarded header is then ignored and
423
+ per-caller limiting collapses into one global counter — the exact failure the
424
+ startup warning covers for an *unset* list, with nothing said when the list is
425
+ merely wrong. The hub now says so once, when a forwarded header arrives from
426
+ an address it does not trust.
427
+
428
+ Unchanged, and confirmed by the same review: the docker policy proxy refused
429
+ every one of thirty-three attempts to reach the daemon past it — privileged
430
+ containers, host mounts, `Mounts` instead of `Binds`, foreign images, prototype
431
+ pollution in the create body, duplicate query parameters, encoded traversal in a
432
+ container name, `exec`, `build`, `networks/create`; and the metadata-document
433
+ fetcher refused literal private addresses, IPv4-mapped ones and public names
434
+ that resolve inward.
435
+
10
436
  ## [0.10.0] - 2026-08-27
11
437
 
12
438
  ### Added
package/README.md CHANGED
@@ -7,10 +7,20 @@
7
7
  [![license](https://img.shields.io/npm/l/%40ni-c%2Fmcp-hub)](LICENSE)
8
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
9
  [![docs](https://img.shields.io/badge/docs-mcp--hub.ni--c.de-informational)](https://mcp-hub.ni-c.de)
10
+ [![Glama](https://glama.ai/mcp/servers/ni-c/mcp-hub/badges/score.svg)](https://glama.ai/mcp/servers/ni-c/mcp-hub)
10
11
  [![sponsor](https://img.shields.io/badge/sponsor-ni--c-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/ni-c)
11
12
 
12
- A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) gateway: it serves
13
- many stdio MCP servers from **one container**, published over HTTPS.
13
+ A dual-era [Model Context Protocol](https://modelcontextprotocol.io) (MCP) gateway: it
14
+ serves many stdio MCP servers from **one container**, published over HTTPS, and speaks
15
+ **both MCP revisions on every endpoint** — `2026-07-28` and `2025-11-25`. The client
16
+ picks, and cannot tell which one it is on from the answers. On the 2026 revision that
17
+ includes **elicitation** — a child server's question reaches the person at the far end
18
+ instead of dying at the gateway
19
+ ([how](https://mcp-hub.ni-c.de/guide/elicitation)) — and **subscriptions**: the hub
20
+ serves `subscriptions/listen` to its clients and subscribes to its children on
21
+ whichever revision _they_ speak, so a server that has never heard of it still reaches
22
+ a client that speaks nothing else
23
+ ([how](https://mcp-hub.ni-c.de/guide/subscriptions)).
14
24
 
15
25
  Lets MCP clients that cannot spawn a local process — ChatGPT connectors, Claude on
16
26
  the Web and in Code, Mistral Le Chat, Cursor, LibreChat and any other
@@ -83,11 +93,63 @@ replaces N containers with one process:
83
93
  servers.
84
94
  - **Stateless Streamable HTTP**: no session state, so claude.ai's
85
95
  reconnect-without-DELETE behaviour cannot leak processes or memory.
96
+ - **Dual-era**: every endpoint — `/hub`, `/<name>/mcp` and `--stdio` — answers
97
+ MCP `2026-07-28` and `2025-11-25` alike; the client picks and cannot tell
98
+ from the answers which it got. On the 2026 revision that includes
99
+ **elicitation**: a server asking the user something returns the question
100
+ rather than pushing it, so it reaches the person at the far end instead of
101
+ dying at the gateway. The hub attributes it to the server that asked, strips
102
+ what could lie about that, drops embedded sampling and roots requests, and
103
+ seals the resumption state against the call it belongs to. `passthrough:
104
+ "off"` withdraws one server's right to ask;
105
+ [details](https://mcp-hub.ni-c.de/guide/elicitation).
106
+ - **Change notifications, in both eras**: a client opens a
107
+ `subscriptions/listen` stream and hears when a child's tools, prompts or
108
+ resources change. The hub subscribes to each child the way that child
109
+ understands — `subscriptions/listen` to a 2026 server, `resources/subscribe`
110
+ to a 2025 one — so the era gap is the gateway's problem rather than either
111
+ end's. The state is the open response, not a session table, so this costs the
112
+ stateless design nothing. A sleeping server watches nothing and is told to
113
+ re-read on waking; `subscriptions: "off"` withdraws one server's right to
114
+ push; [details](https://mcp-hub.ni-c.de/guide/subscriptions).
86
115
  - **Lightweight by design**: one Node process, no database (state is one JSON
87
- file plus a JWT key under `/data`), a handful of runtime dependencies, and
116
+ file plus a signing key under `/data`), six runtime dependencies, and
88
117
  multi-arch images — a stated project goal is to run comfortably on a
89
118
  single-board computer like a Raspberry Pi.
90
119
 
120
+ ## Servers to run behind it
121
+
122
+ The hub is server-agnostic — it serves any stdio MCP server whose entry fits
123
+ Claude Code's `mcpServers` format, which is most of them. These eighteen are
124
+ built and maintained alongside it, so their documentation carries the hub entry
125
+ you need and their tool filters line up with the hub's own `allowTools` /
126
+ `denyTools`:
127
+
128
+ | Server | npm | What it reaches |
129
+ | ---------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------ |
130
+ | [audiobookshelf-mcp](https://audiobookshelf-mcp.ni-c.de) | `audiobookshelf-mcp` | Audiobookshelf — libraries, listening progress, collections and playlists |
131
+ | [caldav-mcp](https://caldav-mcp.ni-c.de) | `@ni-c/caldav-mcp` | CalDAV — events, tasks and journal entries on any server that speaks it |
132
+ | [calibreweb-mcp](https://calibreweb-mcp.ni-c.de) | `calibreweb-mcp` | Calibre-Web — read-only library access through the OPDS feed |
133
+ | [freshrss-mcp](https://freshrss-mcp.ni-c.de) | `@ni-c/freshrss-mcp` | FreshRSS — feeds, categories and articles as plain text, not stream ids |
134
+ | [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 |
135
+ | [healthchecks-mcp](https://healthchecks-mcp.ni-c.de) | `healthchecks-mcp` | Healthchecks — cron and uptime checks, and why one failed |
136
+ | [hetzner-dns-mcp](https://hetzner-dns-mcp.ni-c.de) | `hetzner-dns-mcp` | Hetzner Cloud DNS — zones, record sets and BIND import/export |
137
+ | [imap-mcp](https://imap-mcp.ni-c.de) | `@ni-c/imap-mcp` | IMAP mailboxes — read, search, organise and draft mail; it cannot send |
138
+ | [linkwarden-mcp](https://linkwarden-mcp.ni-c.de) | `linkwarden-mcp` | Linkwarden — bookmarks, collections and the article text it preserved |
139
+ | [mealie-mcp](https://mealie-mcp.ni-c.de) | `@ni-c/mealie-mcp` | Mealie — recipes, meal plans, shopping lists and cookbooks |
140
+ | [ntfy-mcp](https://ntfy-mcp.ni-c.de) | `@ni-c/ntfy-mcp` | ntfy — publish and update notifications, manage users and topic access |
141
+ | [opengist-mcp](https://opengist-mcp.ni-c.de) | `opengist-mcp` | Opengist — gists, revisions, commit history and raw files |
142
+ | [osm-mcp](https://osm-mcp.ni-c.de) | `osm-mcp` | OpenStreetMap — geocoding, routing, isochrones and POI search |
143
+ | [rustpad-mcp](https://rustpad-mcp.ni-c.de) | `rustpad-mcp` | Rustpad — collaborative pads edited through real OT, not overwrites |
144
+ | [smtp-mcp](https://smtp-mcp.ni-c.de) | `@ni-c/smtp-mcp` | SMTP — sends mail, behind a recipient allowlist and a human confirmation |
145
+ | [wg-easy-mcp](https://wg-easy-mcp.ni-c.de) | `wg-easy-mcp` | wg-easy v15+ — the full WireGuard client lifecycle |
146
+ | [wikijs-mcp](https://wikijs-mcp.ni-c.de) | `@ni-c/wikijs-mcp` | Wiki.js — search, read and edit pages, plus assets, users and groups |
147
+ | [woodpecker-ci-mcp](https://woodpecker-ci-mcp.ni-c.de) | `@ni-c/woodpecker-ci-mcp` | Woodpecker CI — repositories, pipelines, logs, secrets and crons |
148
+
149
+ Each one runs perfectly well on its own over stdio. Put them behind the hub when
150
+ you want them reachable from a client that cannot spawn a local process, or when
151
+ you would rather register one connector than eighteen.
152
+
91
153
  ## Configuration
92
154
 
93
155
  `/config/mcp.json` — identical to Claude Code (`${VAR}` expands from the
@@ -366,15 +428,20 @@ immediately. Per-client recipes:
366
428
 
367
429
  ## Notes & limitations
368
430
 
369
- - Stateless transport: server-initiated notifications (`listChanged`,
370
- subscriptions, sampling) are not delivered to clients. Tool/resource/prompt
371
- request-response works fully; the hub's tool cache does follow
372
- `tools/list_changed` internally.
373
- - Access tokens are self-contained 15-minute JWTs. Revoking a client rejects
374
- its existing JWTs and removes all of its refresh tokens. Refresh tokens
375
- rotate; replaying a token
376
- that was already rotated away revokes its whole chain, and a refresh cannot
377
- ask for more scope than the original grant.
431
+ - Change notifications (`listChanged`, resource updates) are carried on
432
+ `2026-07-28` via `subscriptions/listen`, whose state is the open response
433
+ rather than a session table. A `2025-11-25` client is offered neither, because
434
+ that revision needs a channel the stateless transport does not keep — so the
435
+ capability is withheld instead of announced and dropped. An on-demand server
436
+ watches nothing while it sleeps; the subscription is re-established on the
437
+ next wake and the client is told to re-read.
438
+ - Elicitation travels end to end on `2026-07-28`: it is a result rather than a
439
+ push. Sampling and log messages are not forwarded.
440
+ - Access tokens are opaque and last 15 minutes. Revoking a client takes effect
441
+ on its next request rather than when the token expires — the token is a
442
+ reference to a stored record, so withdrawing it is a deletion. Refresh tokens
443
+ rotate; replaying one that was already rotated away is treated as a leak and
444
+ revokes the whole grant, access tokens included.
378
445
  - Upstream auth is fully decoupled from the hub's own OAuth: an expired
379
446
  upstream token just marks that one server `down` (503 on its path, visible
380
447
  in `/health`) — clients never see the upstream's 401.
package/dist/admin.js CHANGED
@@ -4,7 +4,7 @@ import { AuthStore, clientLimitsFromEnv } from './auth/store.js';
4
4
  import { isSafeRedirectUri } from './auth/redirect-uri.js';
5
5
  import { clampDisplayName } from './auth/text.js';
6
6
  import { isClientIdMetadataUrl } from './auth/cimd.js';
7
- import { mintApiToken } from './auth/provider.js';
7
+ import { mintApiToken } from './auth/api-tokens.js';
8
8
  import { loadConfig } from './config.js';
9
9
  import { authFor, requireOAuthServer, startUpstreamLogin, upstreamStatus } from './upstream/login.js';
10
10
  function usage() {
@@ -0,0 +1,27 @@
1
+ import crypto from 'node:crypto';
2
+ import { SignJWT } from 'jose';
3
+ /** Distinguishes admin-minted API tokens from interactive OAuth tokens. */
4
+ export const API_TOKEN_SUBJECT = 'mcp-hub-token';
5
+ /**
6
+ * Mint a long-lived, resource-bound API token for clients that cannot do
7
+ * OAuth (OpenAI Responses API, xAI API, Gemini API, plain-header clients).
8
+ * The JWT is returned exactly once; only its record (jti) is persisted, which
9
+ * is what `tokens list` shows and `tokens revoke` deletes.
10
+ */
11
+ export async function mintApiToken(store, externalUrl, resource, days, label) {
12
+ const id = crypto.randomBytes(8).toString('base64url');
13
+ const now = Math.floor(Date.now() / 1000);
14
+ const expiresAt = now + days * 86_400;
15
+ const token = await new SignJWT({})
16
+ .setProtectedHeader({ alg: 'EdDSA' })
17
+ .setIssuer(new URL(externalUrl).href)
18
+ .setAudience(resource.href)
19
+ .setSubject(API_TOKEN_SUBJECT)
20
+ .setIssuedAt(now)
21
+ .setExpirationTime(expiresAt)
22
+ .setJti(id)
23
+ .sign(store.privateKey);
24
+ store.saveApiToken(id, { label, resource: resource.href, createdAt: now, expiresAt });
25
+ return { id, token, expiresAt };
26
+ }
27
+ //# sourceMappingURL=api-tokens.js.map
@@ -7,7 +7,7 @@
7
7
  * sits there with no visible error, so the pages have to name the one origin
8
8
  * their flow legitimately ends on.
9
9
  */
10
- export function contentSecurityPolicy(formActionOrigins = []) {
10
+ function contentSecurityPolicy(formActionOrigins = []) {
11
11
  const formAction = ["'self'", ...formActionOrigins].join(' ');
12
12
  return `default-src 'none'; style-src 'unsafe-inline'; form-action ${formAction}; frame-ancestors 'none'; base-uri 'none'`;
13
13
  }
@@ -30,4 +30,25 @@ export function allowFormActionTo(res, redirectUri) {
30
30
  return;
31
31
  res.set('Content-Security-Policy', contentSecurityPolicy([origin]));
32
32
  }
33
+ /**
34
+ * The headers every page and token response of the auth surface carries.
35
+ *
36
+ * Shared rather than repeated: the mounted authorization server is a Koa app
37
+ * the Express router never enters, so these have to be put in front of it
38
+ * explicitly -- and two copies of the list would drift the moment one of them
39
+ * gained a header.
40
+ *
41
+ * RFC 6749 §5.1 requires `no-store` on token responses; the login form has no
42
+ * business being cached either. The CSP and the legacy frame header keep an
43
+ * attacker from clickjacking approval or password entry. This application has
44
+ * no legitimate framing use case.
45
+ */
46
+ export const authSecurityHeaders = (_req, res, next) => {
47
+ res.set('Cache-Control', 'no-store');
48
+ res.set('Content-Security-Policy', contentSecurityPolicy());
49
+ res.set('X-Frame-Options', 'DENY');
50
+ res.set('X-Content-Type-Options', 'nosniff');
51
+ res.set('Referrer-Policy', 'no-referrer');
52
+ next();
53
+ };
33
54
  //# sourceMappingURL=headers.js.map