@ni-c/mcp-hub 0.9.2 → 0.11.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 (96) hide show
  1. package/CHANGELOG.md +619 -0
  2. package/README.md +189 -64
  3. package/dist/admin.js +232 -8
  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/api-tokens.js +27 -0
  8. package/dist/auth/api-tokens.js.map +1 -0
  9. package/dist/auth/cimd.js +367 -0
  10. package/dist/auth/cimd.js.map +1 -0
  11. package/dist/auth/consent-page.js +4 -7
  12. package/dist/auth/consent-page.js.map +1 -1
  13. package/dist/auth/headers.js +22 -1
  14. package/dist/auth/headers.js.map +1 -1
  15. package/dist/auth/login-page.js +4 -7
  16. package/dist/auth/login-page.js.map +1 -1
  17. package/dist/auth/oidc/adapter.js +135 -0
  18. package/dist/auth/oidc/adapter.js.map +1 -0
  19. package/dist/auth/oidc/interactions.js +187 -0
  20. package/dist/auth/oidc/interactions.js.map +1 -0
  21. package/dist/auth/oidc/mount.js +144 -0
  22. package/dist/auth/oidc/mount.js.map +1 -0
  23. package/dist/auth/oidc/provider.js +440 -0
  24. package/dist/auth/oidc/provider.js.map +1 -0
  25. package/dist/auth/oidc/quirks.js +234 -0
  26. package/dist/auth/oidc/quirks.js.map +1 -0
  27. package/dist/auth/oidc/verifier.js +121 -0
  28. package/dist/auth/oidc/verifier.js.map +1 -0
  29. package/dist/auth/page.js +22 -0
  30. package/dist/auth/page.js.map +1 -1
  31. package/dist/auth/pinned-fetch.js +129 -0
  32. package/dist/auth/pinned-fetch.js.map +1 -0
  33. package/dist/auth/protected-resource.js +41 -0
  34. package/dist/auth/protected-resource.js.map +1 -0
  35. package/dist/auth/rate-limit.js +156 -0
  36. package/dist/auth/rate-limit.js.map +1 -0
  37. package/dist/auth/redirect-uri.js +90 -0
  38. package/dist/auth/redirect-uri.js.map +1 -0
  39. package/dist/auth/registration.js +146 -0
  40. package/dist/auth/registration.js.map +1 -0
  41. package/dist/auth/session.js +43 -0
  42. package/dist/auth/session.js.map +1 -0
  43. package/dist/auth/signed-token.js +49 -0
  44. package/dist/auth/signed-token.js.map +1 -0
  45. package/dist/auth/store.js +516 -5
  46. package/dist/auth/store.js.map +1 -1
  47. package/dist/auth/text.js +51 -0
  48. package/dist/auth/text.js.map +1 -0
  49. package/dist/config.js +184 -5
  50. package/dist/config.js.map +1 -1
  51. package/dist/docker-proxy/policy.js +1 -0
  52. package/dist/docker-proxy/policy.js.map +1 -1
  53. package/dist/docker-proxy/server.js +1 -0
  54. package/dist/docker-proxy/server.js.map +1 -1
  55. package/dist/elicitation.js +0 -0
  56. package/dist/elicitation.js.map +1 -0
  57. package/dist/forward.js +0 -0
  58. package/dist/forward.js.map +1 -0
  59. package/dist/health.js +22 -3
  60. package/dist/health.js.map +1 -1
  61. package/dist/hub.js +337 -29
  62. package/dist/hub.js.map +1 -1
  63. package/dist/index.js +218 -23
  64. package/dist/index.js.map +1 -1
  65. package/dist/limits.js +13 -1
  66. package/dist/limits.js.map +1 -1
  67. package/dist/mcp-limits.js +14 -2
  68. package/dist/mcp-limits.js.map +1 -1
  69. package/dist/proxy.js +262 -34
  70. package/dist/proxy.js.map +1 -1
  71. package/dist/stdio.js +97 -7
  72. package/dist/stdio.js.map +1 -1
  73. package/dist/subscriptions.js +236 -0
  74. package/dist/subscriptions.js.map +1 -0
  75. package/dist/supervisor.js +472 -28
  76. package/dist/supervisor.js.map +1 -1
  77. package/dist/timings.js +61 -0
  78. package/dist/timings.js.map +1 -0
  79. package/dist/tool-filter.js +59 -0
  80. package/dist/tool-filter.js.map +1 -0
  81. package/dist/transports/docker.js.map +1 -1
  82. package/dist/transports/stream.js +33 -20
  83. package/dist/transports/stream.js.map +1 -1
  84. package/dist/upstream/auth.js +435 -0
  85. package/dist/upstream/auth.js.map +1 -0
  86. package/dist/upstream/login.js +94 -0
  87. package/dist/upstream/login.js.map +1 -0
  88. package/dist/upstream/provider.js +286 -0
  89. package/dist/upstream/provider.js.map +1 -0
  90. package/dist/upstream/routes.js +97 -0
  91. package/dist/upstream/routes.js.map +1 -0
  92. package/package.json +23 -7
  93. package/dist/auth/provider.js +0 -380
  94. package/dist/auth/provider.js.map +0 -1
  95. package/dist/auth/routes.js +0 -223
  96. package/dist/auth/routes.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,625 @@ 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.0] - 2026-09-03
11
+
12
+ ### Added
13
+
14
+ - **The meta-tools answer in both channels.** Five of the six now declare an
15
+ `outputSchema` and return the same object as `structuredContent` as well as
16
+ the JSON text block they always returned. A client that wants to *use* the
17
+ answer no longer has to parse a string and hope; one that wants to read it
18
+ still gets the text, because the SDK does not synthesize a text block for an
19
+ object-shaped value and dropping it would have left older clients with
20
+ nothing.
21
+
22
+ `list_servers` and `list_tools` therefore answer with `{"servers": […]}` and
23
+ `{"tools": […]}` rather than a bare array. That is the breaking part of this
24
+ change, and it is not cosmetic: the 2025 wire cannot carry a non-object
25
+ `structuredContent`, so the SDK wraps one in `{"result": …}` — and an
26
+ array-rooted answer would let a client tell from the payload which protocol
27
+ revision it had been given. The hub's promise is that it cannot. There is a
28
+ test comparing both eras field for field.
29
+
30
+ `call_tool` is the one without a schema. It returns the child's own result,
31
+ and a schema of the hub's would only be honest if the hub wrapped somebody
32
+ else's payload — which would break the passthrough the era matrix pins across
33
+ all four client/child combinations.
34
+
35
+ - **`get_tool_schema` hands on the child's `outputSchema`.** This is the gap the
36
+ rest of the work uncovered. `call_tool` has always returned a child's
37
+ `structuredContent` verbatim, but the schema to validate it against was
38
+ dropped on the way out of `/hub`, so an aggregate client received structured
39
+ data it had no way to check — while `hub-tools.md` claimed structured content
40
+ "passes through". Half of it did.
41
+
42
+ `list_tools` marks such a tool with `hasOutputSchema: true` rather than
43
+ inlining the schema, so a large server's list stays a list. Both follow the
44
+ rule `annotations` already followed: the child's own document, verbatim, and
45
+ the key absent when it declared none.
46
+
47
+ - **`list_tools` and `get_tool_schema` carry a child's tool annotations
48
+ through.** Over `/hub` a client never sees the child's own `tools/list`, so
49
+ those two answers are the only place it can learn that one of two similarly
50
+ named tools deletes and the other does not. They came back as name plus
51
+ description, which left every tool looking identical at exactly the moment a
52
+ model decides which to call. The proxy endpoint `/<name>/mcp` was already
53
+ correct; there is a test for it now too, because "already correct" is a
54
+ property that stops being true silently.
55
+
56
+ Verbatim, not summarised into a marker of the hub's own. The specification
57
+ says a client "MUST consider tool annotations to be untrusted unless they come
58
+ from trusted servers", and the hub is in no position to vouch for a child it
59
+ merely forwards to — a derived `kind` would have been the hub's claim about
60
+ somebody else's server. A child that declared nothing arrives with no
61
+ `annotations` key at all; an empty object would read as all four defaults,
62
+ which is a claim it did not make.
63
+
64
+ - **The six meta-tools annotate themselves**, which they never did. The
65
+ specification gives `destructiveHint` and `openWorldHint` a default of `true`,
66
+ so silence declared `list_servers` a destructive tool in an open world.
67
+ `call_tool` is the one where that really is the answer: whatever the named
68
+ tool does, `call_tool` does.
69
+
70
+ - **The hub speaks both MCP revisions on every endpoint.** `2026-07-28` and
71
+ `2025-11-25`, on `/hub`, on `/<name>/mcp` and over `--stdio`; the client
72
+ picks during its opening exchange and cannot tell from the answers which one
73
+ it got. Which traffic is carried on which revision is a
74
+ [matrix](https://mcp-hub.ni-c.de/reference/standards#what-is-carried-per-revision)
75
+ now, with a test behind every row, because this project has twice announced
76
+ something it did not deliver.
77
+
78
+ The 2025 path is untouched: it is served by the same transport that always
79
+ served it, so a `GET` still opens a stream and a `DELETE` still answers 200
80
+ rather than the 405 the modern handler's own fallback would give. claude.ai
81
+ opens that stream on every reconnect.
82
+
83
+ - **Change notifications travel, on both sides.** A `2026-07-28` client opens a
84
+ `subscriptions/listen` stream and names what it wants — tool, prompt and
85
+ resource list changes, or specific resource URIs — and the hub delivers.
86
+ Upstream it subscribes to each child the way that child understands:
87
+ `subscriptions/listen` to a 2026 server, `resources/subscribe` to a 2025 one.
88
+ So a server that has never heard of the newer mechanism still reaches a client
89
+ that speaks nothing else, which is the common case in practice.
90
+ [Details](https://mcp-hub.ni-c.de/guide/subscriptions).
91
+
92
+ This is the second thing the 2026 revision made possible for a stateless
93
+ gateway, and for the same reason as the first: the state is the open HTTP
94
+ response rather than a session table, so a client reconnecting without closing
95
+ anything leaves nothing behind. One handler per route now outlives the
96
+ request, because it owns those streams — it holds the sockets currently open
97
+ and no record of who opened them.
98
+
99
+ The bookkeeping is a lease per stream rather than a reference count per URI.
100
+ A count cannot tell "nobody wants this any more" from "the one leaving wanted
101
+ it too", and gets it wrong in the direction that silently stops delivering to
102
+ the client that stayed.
103
+
104
+ A sleeping [on-demand](https://mcp-hub.ni-c.de/guide/on-demand) server watches
105
+ nothing: subscribing does not wake it — the acknowledgment comes from the
106
+ cached capabilities — and the subscription is re-established when something
107
+ else does, followed by a re-read signal for everything that client was
108
+ watching. What changed during the nap is not reported, only that there is
109
+ reason to look. `subscriptions: "off"` withdraws one server's right to push.
110
+
111
+ - **An end-to-end suite that runs the hub the way it ships.** Three tiers: in
112
+ this process, as `node dist/index.js`, and as the published image through
113
+ `demo/compose.yml`. It is not part of `npm test`, which stays fast and stays
114
+ the pull-request gate; this one runs nightly, on any pull request that touches
115
+ it, and as a gate on release tags. [What it is
116
+ for](https://github.com/ni-c/mcp-hub/blob/main/e2e/README.md).
117
+
118
+ The tiers exist because a class of question cannot be asked from inside the
119
+ process being tested. `src/index.ts`'s startup block — environment parsing,
120
+ the listener, signal handlers — is entered only when the file is the program.
121
+ `mcp-hub-admin` is a *separate program* sharing `/data` with a running hub,
122
+ and a test that called the same `AuthStore` instance proves the hub can read
123
+ its own memory; that mistake shipped once, as a revocation that reported
124
+ success and did nothing. An `uncaughtException` in-process takes the test
125
+ runner down rather than the hub. And uid 1000, a read-only root filesystem,
126
+ the healthcheck and tini cannot be wrong in a bare process at all.
127
+
128
+ The consumer is a scripted agent rather than a model. It discovers through the
129
+ six meta-tools and then builds its arguments *from the schema the hub
130
+ published*, which is the whole point: a schema damaged in transit — truncated,
131
+ budget-clipped, missing a property it declares required — stops working there
132
+ and nowhere else. A model handed a broken schema improvises around it, and
133
+ improvisation is not an assertion.
134
+
135
+ Alongside it: thirteen fixture servers that each misbehave in one specific way
136
+ no off-the-shelf server does, a four-cell client-era × child-era matrix built
137
+ on one catalogue registered twice so a difference can only be the hub's, raw
138
+ `fetch` conformance checks that assert an HTTP status and a JSON-RPC code
139
+ together, and a recorder for what real clients put on the wire.
140
+
141
+ - **`src/timings.ts`.** The supervisor's ping interval, wake timeout, idle
142
+ sweep and backoff curve read the environment, the same way `mcp-limits.ts`
143
+ already did for the call deadline. `IDLE_TIMEOUT_MS` is the sub-minute sibling
144
+ of `IDLE_TIMEOUT_MINUTES`.
145
+
146
+ Defaults are unchanged, so no deployment behaves differently. What changes is
147
+ that the behaviour becomes observable: at the shipped numbers, watching a
148
+ server fall asleep costs a minute and the five-minute backoff ceiling cannot
149
+ be reached at all. Four minutes of a test suite spent asleep is four minutes
150
+ somebody eventually deletes.
151
+
152
+ - **`describe_connection`, behind `MCP_DIAGNOSTICS`.** A seventh meta-tool that
153
+ answers the one question a client cannot answer for itself: which protocol
154
+ era this connection is on, whether this request carried an elicitation
155
+ capability, and — for a named server — whether a question from it would
156
+ actually reach the person at the far end.
157
+
158
+ It exists because a server falling back to its two-call token looks identical
159
+ to a server that simply did not ask. From inside the client both are silence,
160
+ and only the hub knows which one it was. The first thing it reported in
161
+ anger: a connector on the `2026-07-28` era that declares no `elicitation`
162
+ capability at all — which the era, unlike the older one, would let it do.
163
+
164
+ Off by default. Not for safety — it reports only what the caller's own request
165
+ already carried, and nothing about any other client — but because every tool a
166
+ client can see costs context in every conversation it has, and "six meta-tools
167
+ instead of N×tools" is the argument for the aggregate. With the switch on,
168
+ `/hub` and `--stdio` serve seven.
169
+
170
+ - **One log line when a question is dropped.** When the hub declines to carry a
171
+ child's elicitation it now says so once per client, server and reason, instead
172
+ of leaving the only trace inside a tool result that one caller sees. The
173
+ decision itself moved into a single function that both the forwarding path and
174
+ the new tool call, so the explanation cannot drift from the behaviour.
175
+
176
+ ### Fixed
177
+
178
+ - **The meta-tools no longer advertise an empty schema where they mean
179
+ "anything".** Five fields carry a document the hub does not own — a child's
180
+ `annotations` on `list_tools` and `get_tool_schema`, the two schemas on
181
+ `get_tool_schema`, and the `arguments` of `call_tool` — and zod writes that
182
+ as `"additionalProperties": {}`. An empty schema is legal and means exactly
183
+ what `true` means, but it is the spelling some MCP clients refuse or
184
+ mishandle, which is the worst kind of bug to own: the tool works against the
185
+ client you tested with and fails against the one you did not.
186
+
187
+ Only the emitted JSON Schema changes. The runtime is still as permissive as
188
+ it has to be, and deliberately so: those fields are validated against the zod
189
+ schema, a child is free to put anything in them, and the SDK turns a refused
190
+ answer into an error result. A test walks every schema the hub advertises and
191
+ fails on any node that constrains nothing, so a future zod release cannot put
192
+ the spelling back without saying so.
193
+
194
+ - **A non-object output schema now reaches a 2025 client with its value wrapped
195
+ to match.** The per-server endpoint writes its own `tools/call` handler, and
196
+ such a handler has to run the result through the wire codec itself. It did
197
+ not. The schema half was already being rewritten to `{"result": …}` on the
198
+ way out, so a child whose `outputSchema` describes an array handed a 2025
199
+ client a bare array to validate against a schema saying "object" — correct
200
+ data that looks broken. Both halves are projected now, and a fixture with an
201
+ array-rooted schema pins it on every era pair.
202
+
203
+ The e2e agent, which checks that a result carries everything its schema
204
+ requires, was silently doing nothing on the `/hub` door for the same reason:
205
+ the schema it read from `get_tool_schema` was always `undefined`. It bites
206
+ there now, and it also asserts that the text block and `structuredContent`
207
+ agree.
208
+
209
+ - **Three capabilities the hub announced but did not serve.** `listChanged` for
210
+ tools, prompts and resources is now advertised only on the revision that
211
+ carries it, and is true there; `resources.subscribe` likewise, having been
212
+ stripped outright since 0.6.3. `logging` is no longer advertised at all —
213
+ `logging/setLevel` never had a handler, so a client that believed it got a
214
+ `-32601` at call time, and on `2026-07-28` the level is per-request `_meta`
215
+ with no RPC left to implement.
216
+
217
+ A 2025 client is now told none of the three. That is a visible change, and the
218
+ honest one: it was never going to receive any of them.
219
+
220
+ - **A `subscriptions/listen` POST no longer occupies an in-flight slot.** It is
221
+ a POST whose response stays open for the life of the subscription, so counted
222
+ as work in progress it held one of `MCP_MAX_CONCURRENT_REQUESTS` (default
223
+ four) the entire time — a handful of subscribed clients would have locked
224
+ every tool call on the hub out with a 429 while nothing was running. It is the
225
+ standing channel by another name and is charged to
226
+ `MCP_MAX_CONCURRENT_STREAMS`, where the 2025 era's `GET` already went.
227
+
228
+ - **Elicitation travels end to end.** A child server that needs to ask the
229
+ person at the far end something — `smtp-mcp` before it sends, `imap-mcp`
230
+ before it expunges a mailbox — now reaches them through the hub instead of
231
+ silently falling back to a weaker check. On `2026-07-28` a question is a
232
+ *result*, not a push: the call ends, the person decides, the client retries
233
+ with the answer. Nothing is held open, so the stateless transport is what
234
+ makes this work rather than what prevented it.
235
+
236
+ The hub adds what follows from the question crossing a trust boundary. It is
237
+ attributed to the server that asked, after the text has been stripped of the
238
+ bidirectional and zero-width characters that could visually undo that line.
239
+ Embedded `sampling/createMessage` and `roots/list` requests are dropped and
240
+ named in the log — relaying them would spend the caller's model budget and
241
+ hand out its workspace layout on a child's say-so. The child's `_meta` is
242
+ removed. The resumption state is signed and bound to the server, the tool,
243
+ the OAuth client and the endpoint, so it cannot be pasted onto another call.
244
+
245
+ The capability is mirrored per request from what the client itself declared,
246
+ and never widened — so it is announced only for a call whose answer has
247
+ somewhere to go. A `2025-11-25` client over HTTP is therefore not offered it
248
+ at all, and the child takes its own fallback, which is the same rule this
249
+ project already applies to `listChanged`.
250
+
251
+ `"passthrough": "off"` on a server withdraws its right to put words in front
252
+ of the user without switching it off; `MCP_ELICITATION=false` is the global
253
+ brake. Four further `MCP_ELICITATION_*` variables bound rounds, lifetime,
254
+ message size and payload size. See
255
+ [Elicitation](https://mcp-hub.ni-c.de/guide/elicitation).
256
+
257
+ - **A question from a server that had gone to sleep was lost.** The hub decided
258
+ whether a child could be asked by reading the protocol era off its client,
259
+ and an on-demand child that is asleep has none — so the first tool call after
260
+ an idle nap silently took the weaker path and the second one worked. The wake
261
+ now happens before the decision.
262
+
263
+ ### Changed
264
+
265
+ - **On MCP SDK 2.0.** The single `@modelcontextprotocol/sdk` package has been
266
+ replaced by the split `@modelcontextprotocol/{core,client,server,node,express}`.
267
+ Behaviour is unchanged by the migration itself: no wire format, endpoint or
268
+ response differs, and deployments need do nothing. Speaking `2026-07-28` as
269
+ well is a separate change, listed under Added above — it is what the
270
+ migration was for.
271
+
272
+ Notably **not** installed is `@modelcontextprotocol/server-legacy`, the frozen
273
+ copy of v1's authorization-server helpers that npm marks deprecated on
274
+ install. Replacing the hand-written OAuth server with `oidc-provider` first is
275
+ what made that possible — this migration only had to touch the MCP wire layer.
276
+
277
+ Two things the mechanical migration would have changed quietly, and did not:
278
+ `tools/list` is still walked one page at a time, because v2's `listTools()`
279
+ aggregates the whole pagination internally and would have bypassed the tool
280
+ count and metadata budgets that bound what a hostile child can make the hub
281
+ hold in memory; and a malformed line on a child's stdio is still reported,
282
+ because v2's read buffer skips unparseable lines in silence.
283
+
284
+ - **The authorization server is now `oidc-provider` instead of ~900 lines of
285
+ hand-written OAuth.** Every endpoint keeps its path, the login and consent
286
+ pages are the same pages, and the discovery document advertises everything it
287
+ advertised before — there is a test that compares it field by field against
288
+ the old one and fails on anything that is not a written-down decision.
289
+
290
+ **This is a clean cut, not a migration: every client re-registers and
291
+ authorizes once more.** Tokens issued by the previous server are refused
292
+ rather than honoured, because a credential nothing can revoke is worse than a
293
+ reconnect. Registrations, approvals and API tokens in `state.json` are
294
+ untouched; only the OAuth artifacts are new.
295
+
296
+ Access tokens are **opaque** rather than JWTs. That is what makes
297
+ `mcp-hub-admin clients revoke` take effect on the next call instead of when
298
+ the token expires: oidc-provider never persists a JWT, so a JWT could not be
299
+ withdrawn at all. Nothing that presents a token has to change.
300
+
301
+ Several things got stricter on the way. Replaying a rotated refresh token now
302
+ revokes the grant's access tokens as well. Client assertions may not be valid
303
+ for longer than five minutes. Nothing an authorization server holds is written
304
+ to `state.json` in a form anyone could present — the file used to keep hashes
305
+ of refresh tokens, and now keeps hashes of everything. A `Host` header can no
306
+ longer influence the URLs in the discovery document.
307
+
308
+ Visible differences, none of which change what is allowed: redirects use
309
+ `303` where they used `302`, `invalid_client` may be answered `401` rather
310
+ than `400` (RFC 6749 §5.2 allows either), a rejected redirect URI is reported
311
+ as `invalid_redirect_uri`, and the login page lives at `/interaction/<id>/`
312
+ instead of being rendered by `/authorize` directly. The discovery document
313
+ gained the OpenID fields oidc-provider always publishes; no ID token is ever
314
+ issued.
315
+
316
+ - **Four more reserved server names: `jwks`, `interaction`, `session` and
317
+ `userinfo`.** They are paths the authorization server answers on, and a server
318
+ configured under one of them would shadow the login flow rather than merely be
319
+ unreachable. A configuration using one of these names is now refused at
320
+ startup with the same message as for `token` or `authorize`.
321
+
322
+ - **Both images now run on Node 24 ("Krypton"), the active LTS line, instead of
323
+ Node 26.** Node 26 is Current until October, and a non-LTS build leaves
324
+ `process.release.lts` unset — which is not cosmetic, because libraries branch
325
+ on it. It is also what the CI matrix already tests against, so the container
326
+ and the test runs no longer sat on different majors.
327
+
328
+ Nothing else changes: npm is still replaced wholesale and its three vulnerable
329
+ vendored packages still overwritten in place, verified against the built
330
+ image.
331
+
332
+ ### Security
333
+
334
+ Five findings from an internal review of the authorization surface, which is the
335
+ half that was rewritten onto oidc-provider in this cycle and had not been looked
336
+ at adversarially since.
337
+
338
+ - **`/token` bounds the body it reads.** It is the one provider path whose
339
+ request stream the hub takes away from the library — `stripPhantomSecret` has
340
+ to read it to strip a presented secret — and it read it without a ceiling.
341
+ `/token` cannot ask who is calling before it reads, because the credentials
342
+ are in the body, so an anonymous 300 MB request took the process from 157 MB
343
+ resident to 1.4 GB, and the per-path budget still allows fifty of them per
344
+ caller per window. The ceiling is oidc-provider's own 56 KiB, so nothing a
345
+ legitimate client sends changes, and an oversized body now gets the same
346
+ answer the library would have given it.
347
+
348
+ - **A public client's record no longer carries a secret.** RFC 7591 makes
349
+ `client_secret_basic` the default when `token_endpoint_auth_method` is
350
+ omitted — which is what Claude and ChatGPT both do — so the authorization
351
+ server minted and persisted a real secret before `clientDefaults` rewrote the
352
+ method to `none`. Two consequences, neither visible from outside:
353
+ `state.json` gained a value someone could present, which is the one thing it
354
+ is careful never to hold; and `stripPhantomSecret` gates on the stored client
355
+ having no secret, so it stopped firing for exactly the clients it exists for.
356
+ A connector that echoed the secret from its registration response got
357
+ `401 invalid_client` — the failure quirk 2 was written to prevent. The
358
+ response still carries a secret, because ChatGPT insists on one; only the
359
+ record is cleaned.
360
+
361
+ - **A hundred wrong passwords no longer lock the operator out.** The global
362
+ failure ceiling refused every address once it was reached, so an attacker
363
+ could close the only administrative way in — from one host, in under a second,
364
+ renewable every fifteen minutes — and the operator holding the correct
365
+ password got `429` from an address that had never touched the form. The
366
+ ceiling now refuses the callers that are guessing, and refuses them on their
367
+ first failure rather than their tenth once the hub is under a distributed
368
+ attempt, so the total number of guesses still collapses. What it no longer
369
+ does is let them spend somebody else's budget.
370
+
371
+ - **Every per-caller budget counts an IPv6 /64, not an address.** A /64 is the
372
+ smallest block one subscriber is handed, and a /56 or /48 is what a
373
+ residential line usually gets, so a limiter keyed on the full address counted
374
+ one host as billions of callers: twenty-five registrations from twenty-five
375
+ addresses in one network spent twenty-five budgets of twenty. IPv4 keeps its
376
+ own address, and an IPv4-mapped form is folded back onto it so the same caller
377
+ is not counted twice for arriving over a dual-stack listener. Log lines are
378
+ unchanged — fail2ban still gets the host that actually connected.
379
+
380
+ - **A `TRUSTED_PROXIES` list that never matches is now said out loud.** It is
381
+ compared against the address the connection came from, and the easiest way to
382
+ get it wrong is the least visible: a proxy configured as
383
+ `proxy_pass http://localhost:…` on a dual-stack host arrives over `::1`,
384
+ which `127.0.0.1` does not cover. Every forwarded header is then ignored and
385
+ per-caller limiting collapses into one global counter — the exact failure the
386
+ startup warning covers for an *unset* list, with nothing said when the list is
387
+ merely wrong. The hub now says so once, when a forwarded header arrives from
388
+ an address it does not trust.
389
+
390
+ Unchanged, and confirmed by the same review: the docker policy proxy refused
391
+ every one of thirty-three attempts to reach the daemon past it — privileged
392
+ containers, host mounts, `Mounts` instead of `Binds`, foreign images, prototype
393
+ pollution in the create body, duplicate query parameters, encoded traversal in a
394
+ container name, `exec`, `build`, `networks/create`; and the metadata-document
395
+ fetcher refused literal private addresses, IPv4-mapped ones and public names
396
+ that resolve inward.
397
+
398
+ ## [0.10.0] - 2026-08-27
399
+
400
+ ### Added
401
+
402
+ - `allowTools` and `denyTools` on any server in `mcp.json` decide which of its
403
+ tools the hub exposes. Each entry is an exact tool name or a prefix with a
404
+ single trailing `*`; the allow list decides what is in and the deny list is
405
+ subtracted from it. They apply to every kind of server — stdio, remote, docker
406
+ and socket — because an upstream you do not control is the strongest case for
407
+ filtering one. Nothing changes for a server that sets neither.
408
+
409
+ **It is a boundary, not a tidy-up.** A filtered tool is absent from
410
+ `tools/list` on the server's own path and from `list_tools` on `/hub`, and a
411
+ client that calls it anyway is refused on both routes — before the server is
412
+ woken, so a forbidden name cannot cost a container start. The refusal is the
413
+ same "unknown tool" a server gives for a name it never had: `/hub` tokens go
414
+ to third-party connectors, and enumerating what was hidden would be a
415
+ disclosure in itself.
416
+
417
+ Unlike ni-c's own MCP servers, an entry that matches no tool is not a config
418
+ error — the hub only learns an upstream's tools once it has connected. The
419
+ supervisor logs it at the moment it filters, and `/health` carries `exposed`,
420
+ `hidden` and `unmatched` per filtered server. The latter two only once the
421
+ server has really listed its tools: a snapshot restored from the tool cache is
422
+ already filtered, so `/health` omits them rather than reporting a zero it did
423
+ not earn.
424
+
425
+ Filters tools only: resources, resource templates and prompts on a per-server
426
+ path are untouched. It also does not shrink what the hub accepts — the size
427
+ limits on a `tools/list` answer are measured against the raw upstream, so a
428
+ server that blows them still fails as a whole.
429
+
430
+ - **Client ID Metadata Documents (CIMD), the registration mechanism the MCP
431
+ specification now prefers.** A client may use an HTTPS URL as its `client_id`
432
+ and host its own metadata there; the hub fetches that document, checks that it
433
+ vouches for itself and takes the client's name and redirect URIs from it.
434
+ Nothing is registered and nothing is stored, so a client that reinstalls or
435
+ moves to another machine is still recognised as the same client, and the
436
+ approval you gave it still holds. Dynamic registration remains available and
437
+ advertised beside it, so nothing that works today stops working: a
438
+ spec-compliant client picks CIMD on its own, everything else falls back.
439
+ Closes [#18](https://github.com/ni-c/mcp-hub/issues/18).
440
+
441
+ - **`private_key_jwt` client authentication.** A CIMD client cannot hold a
442
+ shared secret, so a confidential one proves itself with a JWT signed by a key
443
+ it publishes in its own document (`jwks` or `jwks_uri`). This is the path
444
+ ChatGPT's connectors take; without it they were refused with `invalid_client`.
445
+ The assertion must name the client as both `iss` and `sub`, target the token
446
+ endpoint or the issuer, carry a `jti` that is accepted exactly once, and
447
+ expire within five minutes.
448
+
449
+ - **`CLIENT_REGISTRATION`** names the mechanisms a client may use to obtain a
450
+ `client_id` — `cimd`, `dcr`, or both, which is the default. Dropping `dcr`
451
+ removes `registration_endpoint` from the discovery document and makes
452
+ `/register` answer `404`, which is how you retire dynamic registration once
453
+ every client you use supports CIMD. `CIMD_ALLOWED_ORIGINS` restricts which
454
+ origins may serve a metadata document (only origins can be pinned — ChatGPT's
455
+ per-connector document path is random), and `CIMD_ALLOW_PRIVATE_ADDRESSES`
456
+ relaxes the SSRF guard for local development only.
457
+
458
+ - **The authorization page names what cannot be forged.** For a
459
+ metadata-document client it shows the document URL under *Identified by*: the
460
+ name in that document is self-declared, the origin serving it is not. When
461
+ every redirect URI is a loopback address the page says so outright, because a
462
+ code sent to `http://127.0.0.1:…` could be collected by any program on that
463
+ machine.
464
+
465
+ - **The hub can authenticate itself to upstream MCP servers with OAuth.** A
466
+ remote server may carry an `oauth` block instead of a static `Authorization`
467
+ header, and the hub then obtains and refreshes the token itself — no
468
+ `mcp-remote` bridge, no token cache to babysit. It identifies itself with
469
+ credentials the upstream issued (`mode: "static"`, with an optional
470
+ `clientSecret` from `${VAR}`), by registering dynamically (`"dcr"`, RFC 7591)
471
+ or with its own client metadata document (`"cimd"`), and uses either the
472
+ `client_credentials` grant, which needs no attention at all, or
473
+ `authorization_code`, which needs one browser visit started with
474
+ `mcp-hub-admin upstream login <server>`. The CLI prints a URL, the upstream
475
+ redirects back to the hub, and the server connects. `upstream list`, `status`,
476
+ `register`, `refresh` and `logout` cover the rest; `logout` also revokes the
477
+ token (RFC 7009) and deletes a dynamic registration (RFC 7592) at the upstream.
478
+ Replaces the `mcp-remote` workaround the configuration guide used to recommend.
479
+
480
+ - **`mcp-hub-admin clients add`** issues a `client_id` and secret by hand, for a
481
+ client that supports neither dynamic registration nor a metadata document —
482
+ the one case that previously had no answer but an API token. Creating it
483
+ counts as approving it for the redirect URI you named, and it is exempt from
484
+ the lifecycle rules: nothing removes it but `clients delete`.
485
+
486
+ - **Outbound `private_key_jwt`.** An upstream can be told
487
+ `"clientAuth": "private_key_jwt"` and the hub signs an RFC 7523 assertion
488
+ instead of presenting a shared secret. The signing key lives at
489
+ `<DATA_PATH>/upstream-key.pem` and is deliberately not the key that signs the
490
+ hub's own access tokens; its public half travels with the client metadata
491
+ document or the registration request, which is how the upstream verifies it.
492
+
493
+ - **A client metadata document per upstream.** The hub previously published one
494
+ document built from the first server using `mode: "cimd"`, so a second such
495
+ server was registered with the first one's scopes. Each now has its own at
496
+ `/.well-known/mcp-hub-client/<id>.json`, where the identifier is derived from
497
+ the server name rather than being it — the URL is public, the names are not.
498
+
499
+ - **A remote server whose authorization is missing or refused enters a new
500
+ `unauthorized` state** instead of restarting every five minutes for ever. It
501
+ is reported in `/health` and `list_servers`, and the log names the command to
502
+ run. A completed login brings it up again without a restart of the hub.
503
+
504
+ - **A registration lifecycle for dynamic clients.** Anyone may register, so
505
+ registrations no longer stay forever: one that is never approved is dropped
506
+ after `DCR_PENDING_TTL_HOURS` (24), an approved one nobody has used after
507
+ `DCR_INACTIVE_DAYS` (90) along with its approval and refresh tokens, and the
508
+ store holds at most `DCR_MAX_CLIENTS` (500). Reaching the ceiling evicts the
509
+ oldest never-approved registrations; when every one of them has been approved
510
+ the newcomer is refused instead, so registering repeatedly cannot push a
511
+ working connector out. Opening the authorization page counts as use, so a slow
512
+ login is not cut short. The sweep runs at startup and every fifteen minutes,
513
+ and an existing state file is given a fresh clock rather than being read as
514
+ idle since the day each client registered. Client ID Metadata Document clients
515
+ are unaffected — they are never stored.
516
+
517
+ - **Clients can manage their own registration (RFC 7592).** The registration
518
+ response now carries `registration_access_token` and
519
+ `registration_client_uri`, and `GET`, `PUT` and `DELETE` on
520
+ `/register/<client_id>` let a client read, change or remove what it
521
+ registered. Only a hash of the token is stored, so it is shown exactly once.
522
+ `DELETE` takes the approval and every refresh token with it. Changing the
523
+ redirect URIs through `PUT` withdraws the approval — consent was given for a
524
+ destination and does not transfer to a new one — while changing a name or a
525
+ logo leaves it in place. A wrong token and an unknown `client_id` get the same
526
+ answer, so the endpoint cannot be used to enumerate registrations. None of
527
+ this comes from the SDK, whose registration router accepts `POST` and nothing
528
+ else.
529
+
530
+ - `mcp-hub-admin clients delete <client-id>` removes a registration outright,
531
+ where `clients revoke` withdraws access but keeps it, and
532
+ `mcp-hub-admin clients prune [--dry-run]` applies the lifecycle rules on
533
+ demand instead of waiting for the next sweep.
534
+
535
+ - `mcp-hub-admin clients list` now also lists clients that were approved
536
+ without ever being registered, and says which mechanism each one came in
537
+ through. A metadata-document client leaves no registration behind, so its
538
+ approval is the whole record; `clients revoke` works on it either way.
539
+
540
+ - **A `demo/` directory you can run without owning anything.**
541
+ `docker compose up -d` brings up a hub with three fake MCP servers —
542
+ weather, tickets and a small index of these docs — and
543
+ [the page that goes with it](https://mcp-hub.ni-c.de/guide/demo) shows how to
544
+ point the MCP Inspector or MCPJam at it. The servers answer from tables
545
+ compiled into them: no network, no filesystem, no stored state, so the same
546
+ call gives the same answer and nothing a visitor does outlasts the request.
547
+ `demo/token.sh` mints the API tokens. It exists because the first question
548
+ about a gateway is what it looks like from the client side, and until now the
549
+ only way to find out was to deploy one.
550
+
551
+ ### Changed
552
+
553
+ - The README now carries the same eight badges, in the same order, as every other
554
+ MCP server in this family, all of them reading from npm rather than hard-coded;
555
+ the opening follows one shape; and the standalone "Full documentation" line is
556
+ gone, because the docs badge three lines above it points at the same page.
557
+
558
+ - The authorization-server metadata advertises
559
+ `client_id_metadata_document_supported`, and `private_key_jwt` alongside
560
+ `client_secret_post` and `none` in `token_endpoint_auth_methods_supported`.
561
+ The enriched document is served at the root path, the RFC 8414 path-inserted
562
+ form and the OpenID Connect discovery alias alike.
563
+
564
+ ### Security
565
+
566
+ - Metadata documents are fetched from a URL an unauthenticated caller chose, so
567
+ the request is treated as hostile: `https` only, redirects never followed,
568
+ private, loopback, link-local and CGNAT addresses refused after DNS
569
+ resolution, a 5 kB cap enforced while reading, a 5-second timeout and a JSON
570
+ content type required. Documents carrying a `client_secret` or declaring a
571
+ symmetric authentication method are refused outright. Concurrent lookups of
572
+ one URL collapse into a single request, rejections are remembered for 30
573
+ seconds and the cache is bounded, so a `client_id` cannot be used to point the
574
+ hub at a third party. Every rejection answers a bare `invalid_client`; the
575
+ reason goes to the log only, so the admission policy cannot be mapped by
576
+ probing.
577
+
578
+ - **A client declaring `private_key_jwt` must present an assertion.** Client
579
+ authentication is driven by the stored record, and a metadata-document client
580
+ never has a `client_secret` — so a token request that simply omitted
581
+ `client_assertion` was treated as a public client and accepted on its
582
+ `client_id` alone. A leaked refresh token or authorization code was therefore
583
+ redeemable without the private key that exists to prevent exactly that. The
584
+ assertion is now required whenever the document declares it.
585
+
586
+ - **The connection is pinned to the address that was checked.** The SSRF guard
587
+ resolved the hostname and then handed the name to `fetch`, which resolved it
588
+ again; a zone answering differently the second time could move the request
589
+ onto an internal address or a cloud metadata endpoint. The vetted address is
590
+ now what the socket connects to, with the certificate still validated against
591
+ the hostname. The IPv6 forms that carry an IPv4 address (NAT64 `64:ff9b::/96`,
592
+ 6to4 `2002::/16`) and several reserved IPv4 ranges are refused as well.
593
+
594
+ - **The `jwks_uri` fetch is capped at 64 kB.** It inherited the redirect,
595
+ timeout and address guards but not the size limit, and the JWKS is parsed
596
+ whole — an unauthenticated token request naming a document with a hostile
597
+ `jwks_uri` could push an unbounded body into the heap and take the hub, and
598
+ every MCP server it supervises, down with it. The cache of remote key sets is
599
+ now bounded too; entries were created before the signature was checked.
600
+
601
+ - **Untrusted values can no longer forge a log record.** A `client_id` may
602
+ contain newlines — the URL parser strips them, so the value passed every
603
+ structural check while the raw string reached the log, where each line is
604
+ given a valid timestamp. A forged `mcp-hub: authentication failure from …`
605
+ line matches the fail2ban filter this project ships, which made it possible
606
+ to have any address banned by sending unauthenticated requests. Client-chosen
607
+ values are escaped and capped at the point they enter a log line.
608
+
609
+ - **Redirect URIs are held to one rule for both registration mechanisms.**
610
+ Dynamic registration accepted anything outside the SDK's three-scheme
611
+ denylist, including a plaintext `http://` callback on a remote host, which
612
+ delivers the authorization code in the clear. Registration now requires
613
+ `https`, a loopback address, or an application-specific scheme for native
614
+ clients, and answers `400 invalid_client_metadata` otherwise.
615
+
616
+ - Self-declared client names are reduced to a single short line before they are
617
+ stored or shown. They were escaped but unbounded, so a name of several hundred
618
+ characters could push the redirect target and the loopback warning off the
619
+ consent page.
620
+
621
+ - `/health` is authenticated and bound to the `hub` resource; only `/livez` is
622
+ public. A stale comment claimed the opposite, which would have justified
623
+ exposing the deployment topology.
624
+
625
+ - Revocation markers are dropped once they are older than the longest-lived
626
+ refresh token they could reject. They were the one part of the state file
627
+ that only ever grew.
628
+
10
629
  ## [0.9.2] - 2026-08-24
11
630
 
12
631
  ### Fixed