@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.
- package/CHANGELOG.md +619 -0
- package/README.md +189 -64
- package/dist/admin.js +232 -8
- package/dist/admin.js.map +1 -1
- package/dist/auth/address.js +89 -0
- package/dist/auth/address.js.map +1 -0
- package/dist/auth/api-tokens.js +27 -0
- package/dist/auth/api-tokens.js.map +1 -0
- package/dist/auth/cimd.js +367 -0
- package/dist/auth/cimd.js.map +1 -0
- package/dist/auth/consent-page.js +4 -7
- package/dist/auth/consent-page.js.map +1 -1
- package/dist/auth/headers.js +22 -1
- package/dist/auth/headers.js.map +1 -1
- package/dist/auth/login-page.js +4 -7
- package/dist/auth/login-page.js.map +1 -1
- package/dist/auth/oidc/adapter.js +135 -0
- package/dist/auth/oidc/adapter.js.map +1 -0
- package/dist/auth/oidc/interactions.js +187 -0
- package/dist/auth/oidc/interactions.js.map +1 -0
- package/dist/auth/oidc/mount.js +144 -0
- package/dist/auth/oidc/mount.js.map +1 -0
- package/dist/auth/oidc/provider.js +440 -0
- package/dist/auth/oidc/provider.js.map +1 -0
- package/dist/auth/oidc/quirks.js +234 -0
- package/dist/auth/oidc/quirks.js.map +1 -0
- package/dist/auth/oidc/verifier.js +121 -0
- package/dist/auth/oidc/verifier.js.map +1 -0
- package/dist/auth/page.js +22 -0
- package/dist/auth/page.js.map +1 -1
- package/dist/auth/pinned-fetch.js +129 -0
- package/dist/auth/pinned-fetch.js.map +1 -0
- package/dist/auth/protected-resource.js +41 -0
- package/dist/auth/protected-resource.js.map +1 -0
- package/dist/auth/rate-limit.js +156 -0
- package/dist/auth/rate-limit.js.map +1 -0
- package/dist/auth/redirect-uri.js +90 -0
- package/dist/auth/redirect-uri.js.map +1 -0
- package/dist/auth/registration.js +146 -0
- package/dist/auth/registration.js.map +1 -0
- package/dist/auth/session.js +43 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/auth/signed-token.js +49 -0
- package/dist/auth/signed-token.js.map +1 -0
- package/dist/auth/store.js +516 -5
- package/dist/auth/store.js.map +1 -1
- package/dist/auth/text.js +51 -0
- package/dist/auth/text.js.map +1 -0
- package/dist/config.js +184 -5
- package/dist/config.js.map +1 -1
- package/dist/docker-proxy/policy.js +1 -0
- package/dist/docker-proxy/policy.js.map +1 -1
- package/dist/docker-proxy/server.js +1 -0
- package/dist/docker-proxy/server.js.map +1 -1
- package/dist/elicitation.js +0 -0
- package/dist/elicitation.js.map +1 -0
- package/dist/forward.js +0 -0
- package/dist/forward.js.map +1 -0
- package/dist/health.js +22 -3
- package/dist/health.js.map +1 -1
- package/dist/hub.js +337 -29
- package/dist/hub.js.map +1 -1
- package/dist/index.js +218 -23
- package/dist/index.js.map +1 -1
- package/dist/limits.js +13 -1
- package/dist/limits.js.map +1 -1
- package/dist/mcp-limits.js +14 -2
- package/dist/mcp-limits.js.map +1 -1
- package/dist/proxy.js +262 -34
- package/dist/proxy.js.map +1 -1
- package/dist/stdio.js +97 -7
- package/dist/stdio.js.map +1 -1
- package/dist/subscriptions.js +236 -0
- package/dist/subscriptions.js.map +1 -0
- package/dist/supervisor.js +472 -28
- package/dist/supervisor.js.map +1 -1
- package/dist/timings.js +61 -0
- package/dist/timings.js.map +1 -0
- package/dist/tool-filter.js +59 -0
- package/dist/tool-filter.js.map +1 -0
- package/dist/transports/docker.js.map +1 -1
- package/dist/transports/stream.js +33 -20
- package/dist/transports/stream.js.map +1 -1
- package/dist/upstream/auth.js +435 -0
- package/dist/upstream/auth.js.map +1 -0
- package/dist/upstream/login.js +94 -0
- package/dist/upstream/login.js.map +1 -0
- package/dist/upstream/provider.js +286 -0
- package/dist/upstream/provider.js.map +1 -0
- package/dist/upstream/routes.js +97 -0
- package/dist/upstream/routes.js.map +1 -0
- package/package.json +23 -7
- package/dist/auth/provider.js +0 -380
- package/dist/auth/provider.js.map +0 -1
- package/dist/auth/routes.js +0 -223
- 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
|