Changelog
Rendered from CHANGELOG.md in the repository, which is also the source for the GitHub releases.
The format follows Keep a Changelog and the project adheres to Semantic Versioning.
[0.11.4] - 2026-09-24
Security
- An approval for one server was an approval for all of them. The login and consent pages name the resource a client asks for under Requested access, but the approval was stored for the client and its redirect URI only. While the operator's session lasted, a client approved for
/paperless/mcpcould ask for/huband receive a code without any page — a token for every server. The approval now records the resources the page showed, and a request for any other one brings the page back: Approve / Deny within a live session, the sign-in page otherwise. Approvals written by an older version name no resource, so each connector sees the page once more the next time it authorizes; refresh tokens are unaffected.mcp-hub-admin clients listshowsapprovedResources. - A remote upstream's reply had no size limit. The control plane to an upstream's authorization server has capped every response since August; the MCP requests themselves read whatever came back, so one
tools/callanswered with 300 MiB cost the hub 630 MB of heap, and an event without a line end grew without bound in the SDK's SSE parser. Replies are now held to 10 MiB, the limit the byte-stream transports apply to one message — a JSON reply as a whole, an event stream per event, and a declaredcontent-lengthabove it is refused before reading.resources/updatedURIs longer thanMCP_SUBSCRIPTION_MAX_URI_BYTES(8 KiB) are dropped. - The consent page could be made to show a different address. A redirect URI such as
https://claude.ai@attacker.example/cbconnects toattacker.example, and a bidi override or zero-width character in a redirect URI or metadata document URL could reorder or hide the host in the line the operator reads. Both are refused now, for every scheme and both registration mechanisms; percent-encoded bytes are still accepted, and values stored earlier are shown with such characters as visible escapes. /revokewas not rate-limited. It runs the same client authentication as/token,private_key_jwtsignature checks included, and a client can register itself; it now has the same budget as/token, counted separately so a flood of one cannot starve the other.- Requests could drive a crashing server's restarts. Every request to an on-demand server cancelled its crash backoff and reset the give-up count, so a client repeating a call restarted a crashing sandbox container at its own pace — 30 attempts in 12 seconds instead of 4. A request now waits for the scheduled restart; after a give-up the first request retries and further ones within the backoff get the last error.
- DPoP was offered but never checked. oidc-provider enables it by default, so a client that sent a proof received a
DPoPtoken that the hub then accepted as a plain bearer. It is switched off, which is what the standards page already said. - Text from a sandbox or an upstream reached the terminal unescaped. A sandbox container's stderr and an upstream authorization server's error text in
mcp-hub-admin upstream …now go through the same escaping as every other stranger's text, so an escape sequence or a bare CR can no longer rewrite what the operator sees.LOG_FILEwas not affected. - An upstream token with a line break broke its server for good. A token containing CR or LF was stored and then made every request to that server fail in
Headers.set(), while/healthkept showing it up. Tokens, and the RFC 7592 registration token, must now be printable ASCII of at most 16 KiB; a malformed one fails the server's authorization instead of being stored, and its value is never logged.
Reported by the 2026-09-24 internal review.
Fixed
- A wrongly signed JWT cost a signature check. A bearer shaped like an API token is now decoded first and only verified when its algorithm, subject and
jtimatch a live token, which makes rejecting garbage about twenty times cheaper. A valid token is still verified in full, and revocation is still checked after verification. clients revokereported 0 refresh tokens. It counted a map nothing writes any more; it now counts the live ones it invalidates. The revocation itself was always effective.- A character split across two docker frames was garbled in the sandbox's stderr; the stream is now decoded across frame boundaries, and a last line without a newline is written out when the container stops instead of being dropped.
[0.11.3] - 2026-09-12
Security
- A remote upstream could redirect the hub anywhere. The MCP requests to a remote server —
tools/call, the event stream,subscriptions/listen— went out with the platform's default of following every redirect, and neither the SDK's transports nor the hub's own fetch wrappers said otherwise. An upstream answering with aLocationon an internal address had the hub connect there, send the JSON-RPC body and every configured header exceptAuthorizationandCookie, and read the answer as MCP. The guard the authorization server always had now covers the data plane too: a redirect is followed only within the origin of the configuredurl(same scheme, host and port —/mcpto/mcp/keeps working), at most three times, with the platform's method and body rules; anything else fails the request with a reason that names the refused origin and nothing more. Reported by the 2026-09-12 internal review.
Fixed
- A peer could make the hub copy its input quadratically. The byte-stream transports —
type: "unix","tcp"and the docker attach stream — appended every chunk to one growing buffer, so a line or a frame delivered in small pieces cost the event loop a copy of everything so far, per piece: ten megabytes in 4 KiB pieces took close to a second, a 16 MiB docker frame two and a half, and a peer chooses its piece size. Both decoders now collect the pieces and join them once, which is linear; the existing caps (10 MiB per line, 16 MiB per frame) are unchanged, and a property test holds the framing identical however the bytes are cut. - A remote server that failed to connect was logged as "connection closed". The SDK closes the transport before
connect()rejects, so the generic close reason won the race and the rejection — which names the cause and may carry a verdict no restart can fix — was thrown away. The exit is now reported once, from the rejection, so a refused redirect, a TLS failure or an unauthorized upstream reads as what it is.
[0.11.2] - 2026-09-07
Security
- An unset password was an open login. With neither
PASSWORDnorPASSWORD_HASHconfigured,checkPasswordcompared the form field with an empty buffer — which matches an empty field — so anyone who could reach the port approved a client and minted tokens for every server. The hub now starts with its login disabled in that case: a warning on the first log line,503with the reason on the sign-in page, and every login refused, so nothing can be approved. APASSWORD_HASHthat is not a bcrypt hash disables the login the same way instead of falling back toPASSWORD; the warning names the variable and its length, never the value. Refusing to start was considered and rejected: a health check or a directory crawler that runs the image without a password needs the process, not the login, and the security outcome is the same. - The private-address guard for an upstream's authorization server was off for every DNS-named upstream.
privateAllowed()askedisPrivateAddressabout the upstream's hostname; that function answers "refuse" for anything that is not an address literal, and the caller read "refuse" as "the upstream is private, so its authorization server may be too". A public upstream whose metadata pointed the token or registration endpoint at127.0.0.1or169.254.169.254was followed there. The hostname is now resolved and the upstream counts as private only when every answer is; a resolution failure or a mixed answer keeps the guard on; and the endpoints must be https unless the upstream is private. - The native-fetch path of the upstream OAuth client read bodies without a ceiling. Literal-IP and private upstreams bypass the pinned transport and its 256 KiB cap;
boundedResponse()applies the same cap there, including to error bodies and decoded responses. - A forged
stateon/upstream/callbackhung the request. A signature of the right length in characters but not in bytes madetimingSafeEqualthrowRangeError, and the route'svoid (async …)swallowed the rejection, so the browser waited forever.signatureMatcheschecks the shape before comparing,readSessionCookieno longer throws on broken percent-encoding, and both routes are async handlers whose rejection Express answers. - PKCE is required of every client, confidential ones included. oidc-provider's default asks for a
code_challengeonly from public clients; the discovery document and the standards page have said "S256 required" since the first release. A secret proves who redeems a code, the challenge proves it was the one who asked for it. - The session cookie carries the
__Host-prefix behind HTTPS, asdocs/guide/security.mdhas promised since it was written. The browser then refuses the cookie from any other origin, path or domain, so a session somebody obtained cannot be fixed into another browser from a sibling host. Behind plain http, where the prefix is not settable, the bare name stays. - Prototype names as identifiers no longer fault the authorization server. Every map in
state.jsonis keyed by something a caller chose, andJSON.parsehanded them back withObject.prototypebehind them:state.clients['constructor']wasObjectitself, a truthy record that is not a client, and/authorize?client_id=constructoranswered500 server_errorwith a fault in the log. The maps have a null prototype now; an unknown name isundefinedon every path. - A child's words are cleaned and bounded before they reach a tool result.
list_serverscarried a child'stitleverbatim,list_toolsandget_tool_schemaits descriptions at any length, and every failure sentence quoted the child's error message as received — a bidi override reverses the line, a zero-width character hides text a model still reads, an ESC sequence lands in the terminal.childText()strips the same characters an elicitation prompt loses and cuts to the field's size; schemas and annotations stay verbatim by contract. The supervisor'sup (name version)and failure lines, the client id in the activity warning and the upstream's error on the callback page go throughlogSafefor the same reason. - A URL-mode elicitation is forwarded only for an https page. The client opens that page under the hub's own attribution line;
javascript:, plain http, private-use schemes and credentials in the URL are dropped and counted. - A variable called
__proto__in a sandbox secrets file vanished without a word, and an elicitation keyed__proto__was swallowed: on an ordinary object the assignment replaces the prototype instead of adding a key. Both maps are null-prototype now. - Rotated upstream secrets and headers were never applied to a running hub: the credential manager was keyed on a fingerprint that deliberately survives a secret rotation, so the old configuration stayed alive in memory until a restart. The manager is rebuilt when the configuration changes; the stored tokens survive, as they should.
Fixed
- The nightly end-to-end suite had not run since the move to vitest 5. vitest 5 removed the
vitest/reporterssubpath; the budget reporter imported itsReportertype from there, sotypecheck:e2efailed before a single test started, on every tier (#58). The type now comes fromvitest/node. SOCKET_MODEis validated as three or four octal digits;abcused to reachchmodSyncasNaNand end the proxy with a stack trace.- A
CIMD_ALLOWED_ORIGINSentry that does not look like an origin is described by its length in the startup error rather than printed — it sits a few lines fromPASSWORD_HASHin every compose file. - The subscription debounce window flushes early once it holds 1024 distinct events, instead of growing with the resource URIs a child announces.
/.well-known/mcp-hub-client/<id>.jsonhas a rate limit like every other unauthenticated route.- Four source files carried raw NUL, ESC, VT and FF bytes in string and regex literals, which made git treat them as binary and hide every later change from review. They are
\uXXXXescapes now, with the same runtime meaning. - Both images no longer ship yarn and corepack, which nothing in them runs, and the hub image no longer copies
package-lock.jsoninto the runtime layer, which nothing reads once the install has happened.
Changed
- oxlint's
suspiciouscategory is on; 95 findings resolved (mostlyArray#toSorted()over copy-and-sort, helpers that captured nothing hoisted to module scope, and un-shadowed names). The MCP Transport callbacks are set through onesetTransportHandlers()helper, and_meta/_requestHandlersare allowed as protocol-defined names. No runtime behaviour changed. - The documentation says what the code does: the request pipeline parses the body before the per-client gate and has no per-IP limiter on MCP routes;
call_tool's deadline is absolute by default;/revokerevokes the presented access token; the login and consent pages live under/interaction/<uid>/; the reserved-name lists gainedjwks,interaction,sessionanduserinfo; there are ten runtime dependencies; an unusable upstream token is stateunauthorized; the ten supervisor clocks insrc/timings.tsare listed as environment variables; and the 100-entry ceiling on never-approved registrations is written down.
CI
- A dependency bump could break the end-to-end suite without any pull request noticing. The E2E workflow ran on pull requests only for changes under
e2e/and its configs, andci.ymlnever type-checked that tree, so the vitest 5 bump was green and the nightly was the first to disagree. The test job now runstypecheck:e2e(seconds, once per matrix), and changes topackage.jsonorpackage-lock.jsontrigger the E2E workflow on the pull request itself. - The unit tests are type-checked too (
typecheck:test,tsconfig.test.json); the twelve errors it surfaced were narrowing casts in tests. dependency-review-action(pinned, fails on high) checks what a pull request changes in the dependency tree;npm auditonly sees the tree as it is.- The release workflow checks that the CHANGELOG has a section for the tag before
npm publish, not after it in the job that creates the GitHub release. The nightly image build passes the same daily apt epochci.ymldoes. - The fast suite's client helpers list tools before every call, so the SDK's client-side
structuredContentcheck runs on every success path.
[0.11.1] - 2026-09-06
Security
The published image shipped a vulnerable
libssh2, and no amount of rescanning was going to change that.libssh2-1 1.10.0-3+b1— pulled in as a dependency ofgit, which the image installs so servers can be fetched straight from a repository — is affected by CVE-2026-7598 and CVE-2026-58050, both HIGH, both fixed inbookworm-securityas1.10.0-3+deb12u1before the scanner ever reported them. The fix could not reach the image because the apt layer was never rebuilt: the base digest is pinned and the apt command is a constant, so the layer cache answered every build with the packages installed the day it was first built. The image scanned green one afternoon and red three hours later from the same cached layer, and a workflow rerun reproduced the finding exactly.Both Dockerfiles now take an
APT_SECURITY_EPOCHbuild argument that is interpolated into the apt command itself, and CI passes the current UTC date. The layer therefore expires once a day rather than never, while builds within a day stay cached. The argument has to appear in the command — BuildKit keys aRUNon its expanded command line, and an ARG that is merely declared invalidates nothing. In the hub image the npm replacement step moved above that boundary: it is the expensive one, it rots with its pins rather than with time, and it needs neither apt nor the system CA store.This is the second time the same cache has held back a Debian security fix (CVE-2026-56408 in
libexpat1was the first), which is why the remedy is a mechanism rather than another rebuild.
Changed
- The npm tarball no longer carries the
.js.mapsource maps — 61 files and 360 kB unpacked, against 540 kB of actual code.sourceMapstays on and the maps are still built: the integration tests and CI rundist/index.jsout of a checkout, wheresrc/sits right next to it and a stack trace resolves through them. In an installed package it cannot — the sources those maps point at are not part of it.
[0.11.0] - 2026-09-03
Added
The meta-tools answer in both channels. Five of the six now declare an
outputSchemaand return the same object asstructuredContentas well as the JSON text block they always returned. A client that wants to use the answer no longer has to parse a string and hope; one that wants to read it still gets the text, because the SDK does not synthesize a text block for an object-shaped value and dropping it would have left older clients with nothing.list_serversandlist_toolstherefore answer with{"servers": […]}and{"tools": […]}rather than a bare array. That is the breaking part of this change, and it is not cosmetic: the 2025 wire cannot carry a non-objectstructuredContent, so the SDK wraps one in{"result": …}— and an array-rooted answer would let a client tell from the payload which protocol revision it had been given. The hub's promise is that it cannot. There is a test comparing both eras field for field.call_toolis the one without a schema. It returns the child's own result, and a schema of the hub's would only be honest if the hub wrapped somebody else's payload — which would break the passthrough the era matrix pins across all four client/child combinations.get_tool_schemahands on the child'soutputSchema. This is the gap the rest of the work uncovered.call_toolhas always returned a child'sstructuredContentverbatim, but the schema to validate it against was dropped on the way out of/hub, so an aggregate client received structured data it had no way to check — whilehub-tools.mdclaimed structured content "passes through". Half of it did.list_toolsmarks such a tool withhasOutputSchema: truerather than inlining the schema, so a large server's list stays a list. Both follow the ruleannotationsalready followed: the child's own document, verbatim, and the key absent when it declared none.list_toolsandget_tool_schemacarry a child's tool annotations through. Over/huba client never sees the child's owntools/list, so those two answers are the only place it can learn that one of two similarly named tools deletes and the other does not. They came back as name plus description, which left every tool looking identical at exactly the moment a model decides which to call. The proxy endpoint/<name>/mcpwas already correct; there is a test for it now too, because "already correct" is a property that stops being true silently.Verbatim, not summarised into a marker of the hub's own. The specification says a client "MUST consider tool annotations to be untrusted unless they come from trusted servers", and the hub is in no position to vouch for a child it merely forwards to — a derived
kindwould have been the hub's claim about somebody else's server. A child that declared nothing arrives with noannotationskey at all; an empty object would read as all four defaults, which is a claim it did not make.The six meta-tools annotate themselves, which they never did. The specification gives
destructiveHintandopenWorldHinta default oftrue, so silence declaredlist_serversa destructive tool in an open world.call_toolis the one where that really is the answer: whatever the named tool does,call_tooldoes.The hub speaks both MCP revisions on every endpoint.
2026-07-28and2025-11-25, on/hub, on/<name>/mcpand over--stdio; the client picks during its opening exchange and cannot tell from the answers which one it got. Which traffic is carried on which revision is a matrix now, with a test behind every row, because this project has twice announced something it did not deliver.The 2025 path is untouched: it is served by the same transport that always served it, so a
GETstill opens a stream and aDELETEstill answers 200 rather than the 405 the modern handler's own fallback would give. claude.ai opens that stream on every reconnect.Change notifications travel, on both sides. A
2026-07-28client opens asubscriptions/listenstream and names what it wants — tool, prompt and resource list changes, or specific resource URIs — and the hub delivers. Upstream it subscribes to each child the way that child understands:subscriptions/listento a 2026 server,resources/subscribeto a 2025 one. So a server that has never heard of the newer mechanism still reaches a client that speaks nothing else, which is the common case in practice. Details.This is the second thing the 2026 revision made possible for a stateless gateway, and for the same reason as the first: the state is the open HTTP response rather than a session table, so a client reconnecting without closing anything leaves nothing behind. One handler per route now outlives the request, because it owns those streams — it holds the sockets currently open and no record of who opened them.
The bookkeeping is a lease per stream rather than a reference count per URI. A count cannot tell "nobody wants this any more" from "the one leaving wanted it too", and gets it wrong in the direction that silently stops delivering to the client that stayed.
A sleeping on-demand server watches nothing: subscribing does not wake it — the acknowledgment comes from the cached capabilities — and the subscription is re-established when something else does, followed by a re-read signal for everything that client was watching. What changed during the nap is not reported, only that there is reason to look.
subscriptions: "off"withdraws one server's right to push.An end-to-end suite that runs the hub the way it ships. Three tiers: in this process, as
node dist/index.js, and as the published image throughdemo/compose.yml. It is not part ofnpm test, which stays fast and stays the pull-request gate; this one runs nightly, on any pull request that touches it, and as a gate on release tags. What it is for.The tiers exist because a class of question cannot be asked from inside the process being tested.
src/index.ts's startup block — environment parsing, the listener, signal handlers — is entered only when the file is the program.mcp-hub-adminis a separate program sharing/datawith a running hub, and a test that called the sameAuthStoreinstance proves the hub can read its own memory; that mistake shipped once, as a revocation that reported success and did nothing. AnuncaughtExceptionin-process takes the test runner down rather than the hub. And uid 1000, a read-only root filesystem, the healthcheck and tini cannot be wrong in a bare process at all.The consumer is a scripted agent rather than a model. It discovers through the six meta-tools and then builds its arguments from the schema the hub published, which is the whole point: a schema damaged in transit — truncated, budget-clipped, missing a property it declares required — stops working there and nowhere else. A model handed a broken schema improvises around it, and improvisation is not an assertion.
Alongside it: thirteen fixture servers that each misbehave in one specific way no off-the-shelf server does, a four-cell client-era × child-era matrix built on one catalogue registered twice so a difference can only be the hub's, raw
fetchconformance checks that assert an HTTP status and a JSON-RPC code together, and a recorder for what real clients put on the wire.src/timings.ts. The supervisor's ping interval, wake timeout, idle sweep and backoff curve read the environment, the same waymcp-limits.tsalready did for the call deadline.IDLE_TIMEOUT_MSis the sub-minute sibling ofIDLE_TIMEOUT_MINUTES.Defaults are unchanged, so no deployment behaves differently. What changes is that the behaviour becomes observable: at the shipped numbers, watching a server fall asleep costs a minute and the five-minute backoff ceiling cannot be reached at all. Four minutes of a test suite spent asleep is four minutes somebody eventually deletes.
describe_connection, behindMCP_DIAGNOSTICS. A seventh meta-tool that answers the one question a client cannot answer for itself: which protocol era this connection is on, whether this request carried an elicitation capability, and — for a named server — whether a question from it would actually reach the person at the far end.It exists because a server falling back to its two-call token looks identical to a server that simply did not ask. From inside the client both are silence, and only the hub knows which one it was. The first thing it reported in anger: a connector on the
2026-07-28era that declares noelicitationcapability at all — which the era, unlike the older one, would let it do.Off by default. Not for safety — it reports only what the caller's own request already carried, and nothing about any other client — but because every tool a client can see costs context in every conversation it has, and "six meta-tools instead of N×tools" is the argument for the aggregate. With the switch on,
/huband--stdioserve seven.One log line when a question is dropped. When the hub declines to carry a child's elicitation it now says so once per client, server and reason, instead of leaving the only trace inside a tool result that one caller sees. The decision itself moved into a single function that both the forwarding path and the new tool call, so the explanation cannot drift from the behaviour.
Fixed
The meta-tools no longer advertise an empty schema where they mean "anything". Five fields carry a document the hub does not own — a child's
annotationsonlist_toolsandget_tool_schema, the two schemas onget_tool_schema, and theargumentsofcall_tool— and zod writes that as"additionalProperties": {}. An empty schema is legal and means exactly whattruemeans, but it is the spelling some MCP clients refuse or mishandle, which is the worst kind of bug to own: the tool works against the client you tested with and fails against the one you did not.Only the emitted JSON Schema changes. The runtime is still as permissive as it has to be, and deliberately so: those fields are validated against the zod schema, a child is free to put anything in them, and the SDK turns a refused answer into an error result. A test walks every schema the hub advertises and fails on any node that constrains nothing, so a future zod release cannot put the spelling back without saying so.
A non-object output schema now reaches a 2025 client with its value wrapped to match. The per-server endpoint writes its own
tools/callhandler, and such a handler has to run the result through the wire codec itself. It did not. The schema half was already being rewritten to{"result": …}on the way out, so a child whoseoutputSchemadescribes an array handed a 2025 client a bare array to validate against a schema saying "object" — correct data that looks broken. Both halves are projected now, and a fixture with an array-rooted schema pins it on every era pair.The e2e agent, which checks that a result carries everything its schema requires, was silently doing nothing on the
/hubdoor for the same reason: the schema it read fromget_tool_schemawas alwaysundefined. It bites there now, and it also asserts that the text block andstructuredContentagree.Three capabilities the hub announced but did not serve.
listChangedfor tools, prompts and resources is now advertised only on the revision that carries it, and is true there;resources.subscribelikewise, having been stripped outright since 0.6.3.loggingis no longer advertised at all —logging/setLevelnever had a handler, so a client that believed it got a-32601at call time, and on2026-07-28the level is per-request_metawith no RPC left to implement.A 2025 client is now told none of the three. That is a visible change, and the honest one: it was never going to receive any of them.
A
subscriptions/listenPOST no longer occupies an in-flight slot. It is a POST whose response stays open for the life of the subscription, so counted as work in progress it held one ofMCP_MAX_CONCURRENT_REQUESTS(default four) the entire time — a handful of subscribed clients would have locked every tool call on the hub out with a 429 while nothing was running. It is the standing channel by another name and is charged toMCP_MAX_CONCURRENT_STREAMS, where the 2025 era'sGETalready went.Elicitation travels end to end. A child server that needs to ask the person at the far end something —
smtp-mcpbefore it sends,imap-mcpbefore it expunges a mailbox — now reaches them through the hub instead of silently falling back to a weaker check. On2026-07-28a question is a result, not a push: the call ends, the person decides, the client retries with the answer. Nothing is held open, so the stateless transport is what makes this work rather than what prevented it.The hub adds what follows from the question crossing a trust boundary. It is attributed to the server that asked, after the text has been stripped of the bidirectional and zero-width characters that could visually undo that line. Embedded
sampling/createMessageandroots/listrequests are dropped and named in the log — relaying them would spend the caller's model budget and hand out its workspace layout on a child's say-so. The child's_metais removed. The resumption state is signed and bound to the server, the tool, the OAuth client and the endpoint, so it cannot be pasted onto another call.The capability is mirrored per request from what the client itself declared, and never widened — so it is announced only for a call whose answer has somewhere to go. A
2025-11-25client over HTTP is therefore not offered it at all, and the child takes its own fallback, which is the same rule this project already applies tolistChanged."passthrough": "off"on a server withdraws its right to put words in front of the user without switching it off;MCP_ELICITATION=falseis the global brake. Four furtherMCP_ELICITATION_*variables bound rounds, lifetime, message size and payload size. See Elicitation.A question from a server that had gone to sleep was lost. The hub decided whether a child could be asked by reading the protocol era off its client, and an on-demand child that is asleep has none — so the first tool call after an idle nap silently took the weaker path and the second one worked. The wake now happens before the decision.
Changed
On MCP SDK 2.0. The single
@modelcontextprotocol/sdkpackage has been replaced by the split@modelcontextprotocol/{core,client,server,node,express}. Behaviour is unchanged by the migration itself: no wire format, endpoint or response differs, and deployments need do nothing. Speaking2026-07-28as well is a separate change, listed under Added above — it is what the migration was for.Notably not installed is
@modelcontextprotocol/server-legacy, the frozen copy of v1's authorization-server helpers that npm marks deprecated on install. Replacing the hand-written OAuth server withoidc-providerfirst is what made that possible — this migration only had to touch the MCP wire layer.Two things the mechanical migration would have changed quietly, and did not:
tools/listis still walked one page at a time, because v2'slistTools()aggregates the whole pagination internally and would have bypassed the tool count and metadata budgets that bound what a hostile child can make the hub hold in memory; and a malformed line on a child's stdio is still reported, because v2's read buffer skips unparseable lines in silence.The authorization server is now
oidc-providerinstead of ~900 lines of hand-written OAuth. Every endpoint keeps its path, the login and consent pages are the same pages, and the discovery document advertises everything it advertised before — there is a test that compares it field by field against the old one and fails on anything that is not a written-down decision.This is a clean cut, not a migration: every client re-registers and authorizes once more. Tokens issued by the previous server are refused rather than honoured, because a credential nothing can revoke is worse than a reconnect. Registrations, approvals and API tokens in
state.jsonare untouched; only the OAuth artifacts are new.Access tokens are opaque rather than JWTs. That is what makes
mcp-hub-admin clients revoketake effect on the next call instead of when the token expires: oidc-provider never persists a JWT, so a JWT could not be withdrawn at all. Nothing that presents a token has to change.Several things got stricter on the way. Replaying a rotated refresh token now revokes the grant's access tokens as well. Client assertions may not be valid for longer than five minutes. Nothing an authorization server holds is written to
state.jsonin a form anyone could present — the file used to keep hashes of refresh tokens, and now keeps hashes of everything. AHostheader can no longer influence the URLs in the discovery document.Visible differences, none of which change what is allowed: redirects use
303where they used302,invalid_clientmay be answered401rather than400(RFC 6749 §5.2 allows either), a rejected redirect URI is reported asinvalid_redirect_uri, and the login page lives at/interaction/<id>/instead of being rendered by/authorizedirectly. The discovery document gained the OpenID fields oidc-provider always publishes; no ID token is ever issued.Four more reserved server names:
jwks,interaction,sessionanduserinfo. They are paths the authorization server answers on, and a server configured under one of them would shadow the login flow rather than merely be unreachable. A configuration using one of these names is now refused at startup with the same message as fortokenorauthorize.Both images now run on Node 24 ("Krypton"), the active LTS line, instead of Node 26. Node 26 is Current until October, and a non-LTS build leaves
process.release.ltsunset — which is not cosmetic, because libraries branch on it. It is also what the CI matrix already tests against, so the container and the test runs no longer sat on different majors.Nothing else changes: npm is still replaced wholesale and its three vulnerable vendored packages still overwritten in place, verified against the built image.
Security
Five findings from an internal review of the authorization surface, which is the half that was rewritten onto oidc-provider in this cycle and had not been looked at adversarially since.
/tokenbounds the body it reads. It is the one provider path whose request stream the hub takes away from the library —stripPhantomSecrethas to read it to strip a presented secret — and it read it without a ceiling./tokencannot ask who is calling before it reads, because the credentials are in the body, so an anonymous 300 MB request took the process from 157 MB resident to 1.4 GB, and the per-path budget still allows fifty of them per caller per window. The ceiling is oidc-provider's own 56 KiB, so nothing a legitimate client sends changes, and an oversized body now gets the same answer the library would have given it.A public client's record no longer carries a secret. RFC 7591 makes
client_secret_basicthe default whentoken_endpoint_auth_methodis omitted — which is what Claude and ChatGPT both do — so the authorization server minted and persisted a real secret beforeclientDefaultsrewrote the method tonone. Two consequences, neither visible from outside:state.jsongained a value someone could present, which is the one thing it is careful never to hold; andstripPhantomSecretgates on the stored client having no secret, so it stopped firing for exactly the clients it exists for. A connector that echoed the secret from its registration response got401 invalid_client— the failure quirk 2 was written to prevent. The response still carries a secret, because ChatGPT insists on one; only the record is cleaned.A hundred wrong passwords no longer lock the operator out. The global failure ceiling refused every address once it was reached, so an attacker could close the only administrative way in — from one host, in under a second, renewable every fifteen minutes — and the operator holding the correct password got
429from an address that had never touched the form. The ceiling now refuses the callers that are guessing, and refuses them on their first failure rather than their tenth once the hub is under a distributed attempt, so the total number of guesses still collapses. What it no longer does is let them spend somebody else's budget.Every per-caller budget counts an IPv6 /64, not an address. A /64 is the smallest block one subscriber is handed, and a /56 or /48 is what a residential line usually gets, so a limiter keyed on the full address counted one host as billions of callers: twenty-five registrations from twenty-five addresses in one network spent twenty-five budgets of twenty. IPv4 keeps its own address, and an IPv4-mapped form is folded back onto it so the same caller is not counted twice for arriving over a dual-stack listener. Log lines are unchanged — fail2ban still gets the host that actually connected.
A
TRUSTED_PROXIESlist that never matches is now said out loud. It is compared against the address the connection came from, and the easiest way to get it wrong is the least visible: a proxy configured asproxy_pass http://localhost:…on a dual-stack host arrives over::1, which127.0.0.1does not cover. Every forwarded header is then ignored and per-caller limiting collapses into one global counter — the exact failure the startup warning covers for an unset list, with nothing said when the list is merely wrong. The hub now says so once, when a forwarded header arrives from an address it does not trust.
Unchanged, and confirmed by the same review: the docker policy proxy refused every one of thirty-three attempts to reach the daemon past it — privileged containers, host mounts, Mounts instead of Binds, foreign images, prototype pollution in the create body, duplicate query parameters, encoded traversal in a container name, exec, build, networks/create; and the metadata-document fetcher refused literal private addresses, IPv4-mapped ones and public names that resolve inward.
[0.10.0] - 2026-08-27
Added
allowToolsanddenyToolson any server inmcp.jsondecide which of its tools the hub exposes. Each entry is an exact tool name or a prefix with a single trailing*; the allow list decides what is in and the deny list is subtracted from it. They apply to every kind of server — stdio, remote, docker and socket — because an upstream you do not control is the strongest case for filtering one. Nothing changes for a server that sets neither.It is a boundary, not a tidy-up. A filtered tool is absent from
tools/liston the server's own path and fromlist_toolson/hub, and a client that calls it anyway is refused on both routes — before the server is woken, so a forbidden name cannot cost a container start. The refusal is the same "unknown tool" a server gives for a name it never had:/hubtokens go to third-party connectors, and enumerating what was hidden would be a disclosure in itself.Unlike ni-c's own MCP servers, an entry that matches no tool is not a config error — the hub only learns an upstream's tools once it has connected. The supervisor logs it at the moment it filters, and
/healthcarriesexposed,hiddenandunmatchedper filtered server. The latter two only once the server has really listed its tools: a snapshot restored from the tool cache is already filtered, so/healthomits them rather than reporting a zero it did not earn.Filters tools only: resources, resource templates and prompts on a per-server path are untouched. It also does not shrink what the hub accepts — the size limits on a
tools/listanswer are measured against the raw upstream, so a server that blows them still fails as a whole.Client ID Metadata Documents (CIMD), the registration mechanism the MCP specification now prefers. A client may use an HTTPS URL as its
client_idand host its own metadata there; the hub fetches that document, checks that it vouches for itself and takes the client's name and redirect URIs from it. Nothing is registered and nothing is stored, so a client that reinstalls or moves to another machine is still recognised as the same client, and the approval you gave it still holds. Dynamic registration remains available and advertised beside it, so nothing that works today stops working: a spec-compliant client picks CIMD on its own, everything else falls back. Closes #18.private_key_jwtclient authentication. A CIMD client cannot hold a shared secret, so a confidential one proves itself with a JWT signed by a key it publishes in its own document (jwksorjwks_uri). This is the path ChatGPT's connectors take; without it they were refused withinvalid_client. The assertion must name the client as bothissandsub, target the token endpoint or the issuer, carry ajtithat is accepted exactly once, and expire within five minutes.CLIENT_REGISTRATIONnames the mechanisms a client may use to obtain aclient_id—cimd,dcr, or both, which is the default. Droppingdcrremovesregistration_endpointfrom the discovery document and makes/registeranswer404, which is how you retire dynamic registration once every client you use supports CIMD.CIMD_ALLOWED_ORIGINSrestricts which origins may serve a metadata document (only origins can be pinned — ChatGPT's per-connector document path is random), andCIMD_ALLOW_PRIVATE_ADDRESSESrelaxes the SSRF guard for local development only.The authorization page names what cannot be forged. For a metadata-document client it shows the document URL under Identified by: the name in that document is self-declared, the origin serving it is not. When every redirect URI is a loopback address the page says so outright, because a code sent to
http://127.0.0.1:…could be collected by any program on that machine.The hub can authenticate itself to upstream MCP servers with OAuth. A remote server may carry an
oauthblock instead of a staticAuthorizationheader, and the hub then obtains and refreshes the token itself — nomcp-remotebridge, no token cache to babysit. It identifies itself with credentials the upstream issued (mode: "static", with an optionalclientSecretfrom${VAR}), by registering dynamically ("dcr", RFC 7591) or with its own client metadata document ("cimd"), and uses either theclient_credentialsgrant, which needs no attention at all, orauthorization_code, which needs one browser visit started withmcp-hub-admin upstream login <server>. The CLI prints a URL, the upstream redirects back to the hub, and the server connects.upstream list,status,register,refreshandlogoutcover the rest;logoutalso revokes the token (RFC 7009) and deletes a dynamic registration (RFC 7592) at the upstream. Replaces themcp-remoteworkaround the configuration guide used to recommend.mcp-hub-admin clients addissues aclient_idand secret by hand, for a client that supports neither dynamic registration nor a metadata document — the one case that previously had no answer but an API token. Creating it counts as approving it for the redirect URI you named, and it is exempt from the lifecycle rules: nothing removes it butclients delete.Outbound
private_key_jwt. An upstream can be told"clientAuth": "private_key_jwt"and the hub signs an RFC 7523 assertion instead of presenting a shared secret. The signing key lives at<DATA_PATH>/upstream-key.pemand is deliberately not the key that signs the hub's own access tokens; its public half travels with the client metadata document or the registration request, which is how the upstream verifies it.A client metadata document per upstream. The hub previously published one document built from the first server using
mode: "cimd", so a second such server was registered with the first one's scopes. Each now has its own at/.well-known/mcp-hub-client/<id>.json, where the identifier is derived from the server name rather than being it — the URL is public, the names are not.A remote server whose authorization is missing or refused enters a new
unauthorizedstate instead of restarting every five minutes for ever. It is reported in/healthandlist_servers, and the log names the command to run. A completed login brings it up again without a restart of the hub.A registration lifecycle for dynamic clients. Anyone may register, so registrations no longer stay forever: one that is never approved is dropped after
DCR_PENDING_TTL_HOURS(24), an approved one nobody has used afterDCR_INACTIVE_DAYS(90) along with its approval and refresh tokens, and the store holds at mostDCR_MAX_CLIENTS(500). Reaching the ceiling evicts the oldest never-approved registrations; when every one of them has been approved the newcomer is refused instead, so registering repeatedly cannot push a working connector out. Opening the authorization page counts as use, so a slow login is not cut short. The sweep runs at startup and every fifteen minutes, and an existing state file is given a fresh clock rather than being read as idle since the day each client registered. Client ID Metadata Document clients are unaffected — they are never stored.Clients can manage their own registration (RFC 7592). The registration response now carries
registration_access_tokenandregistration_client_uri, andGET,PUTandDELETEon/register/<client_id>let a client read, change or remove what it registered. Only a hash of the token is stored, so it is shown exactly once.DELETEtakes the approval and every refresh token with it. Changing the redirect URIs throughPUTwithdraws the approval — consent was given for a destination and does not transfer to a new one — while changing a name or a logo leaves it in place. A wrong token and an unknownclient_idget the same answer, so the endpoint cannot be used to enumerate registrations. None of this comes from the SDK, whose registration router acceptsPOSTand nothing else.mcp-hub-admin clients delete <client-id>removes a registration outright, whereclients revokewithdraws access but keeps it, andmcp-hub-admin clients prune [--dry-run]applies the lifecycle rules on demand instead of waiting for the next sweep.mcp-hub-admin clients listnow also lists clients that were approved without ever being registered, and says which mechanism each one came in through. A metadata-document client leaves no registration behind, so its approval is the whole record;clients revokeworks on it either way.A
demo/directory you can run without owning anything.docker compose up -dbrings up a hub with three fake MCP servers — weather, tickets and a small index of these docs — and the page that goes with it shows how to point the MCP Inspector or MCPJam at it. The servers answer from tables compiled into them: no network, no filesystem, no stored state, so the same call gives the same answer and nothing a visitor does outlasts the request.demo/token.shmints the API tokens. It exists because the first question about a gateway is what it looks like from the client side, and until now the only way to find out was to deploy one.
Changed
The README now carries the same eight badges, in the same order, as every other MCP server in this family, all of them reading from npm rather than hard-coded; the opening follows one shape; and the standalone "Full documentation" line is gone, because the docs badge three lines above it points at the same page.
The authorization-server metadata advertises
client_id_metadata_document_supported, andprivate_key_jwtalongsideclient_secret_postandnoneintoken_endpoint_auth_methods_supported. The enriched document is served at the root path, the RFC 8414 path-inserted form and the OpenID Connect discovery alias alike.
Security
Metadata documents are fetched from a URL an unauthenticated caller chose, so the request is treated as hostile:
httpsonly, redirects never followed, private, loopback, link-local and CGNAT addresses refused after DNS resolution, a 5 kB cap enforced while reading, a 5-second timeout and a JSON content type required. Documents carrying aclient_secretor declaring a symmetric authentication method are refused outright. Concurrent lookups of one URL collapse into a single request, rejections are remembered for 30 seconds and the cache is bounded, so aclient_idcannot be used to point the hub at a third party. Every rejection answers a bareinvalid_client; the reason goes to the log only, so the admission policy cannot be mapped by probing.A client declaring
private_key_jwtmust present an assertion. Client authentication is driven by the stored record, and a metadata-document client never has aclient_secret— so a token request that simply omittedclient_assertionwas treated as a public client and accepted on itsclient_idalone. A leaked refresh token or authorization code was therefore redeemable without the private key that exists to prevent exactly that. The assertion is now required whenever the document declares it.The connection is pinned to the address that was checked. The SSRF guard resolved the hostname and then handed the name to
fetch, which resolved it again; a zone answering differently the second time could move the request onto an internal address or a cloud metadata endpoint. The vetted address is now what the socket connects to, with the certificate still validated against the hostname. The IPv6 forms that carry an IPv4 address (NAT6464:ff9b::/96, 6to42002::/16) and several reserved IPv4 ranges are refused as well.The
jwks_urifetch is capped at 64 kB. It inherited the redirect, timeout and address guards but not the size limit, and the JWKS is parsed whole — an unauthenticated token request naming a document with a hostilejwks_uricould push an unbounded body into the heap and take the hub, and every MCP server it supervises, down with it. The cache of remote key sets is now bounded too; entries were created before the signature was checked.Untrusted values can no longer forge a log record. A
client_idmay contain newlines — the URL parser strips them, so the value passed every structural check while the raw string reached the log, where each line is given a valid timestamp. A forgedmcp-hub: authentication failure from …line matches the fail2ban filter this project ships, which made it possible to have any address banned by sending unauthenticated requests. Client-chosen values are escaped and capped at the point they enter a log line.Redirect URIs are held to one rule for both registration mechanisms. Dynamic registration accepted anything outside the SDK's three-scheme denylist, including a plaintext
http://callback on a remote host, which delivers the authorization code in the clear. Registration now requireshttps, a loopback address, or an application-specific scheme for native clients, and answers400 invalid_client_metadataotherwise.Self-declared client names are reduced to a single short line before they are stored or shown. They were escaped but unbounded, so a name of several hundred characters could push the redirect target and the loopback warning off the consent page.
/healthis authenticated and bound to thehubresource; only/livezis public. A stale comment claimed the opposite, which would have justified exposing the deployment topology.Revocation markers are dropped once they are older than the longest-lived refresh token they could reject. They were the one part of the state file that only ever grew.
[0.9.2] - 2026-08-24
Fixed
- A client locked itself out of the hub after four connected sessions. Streamable HTTP uses a
GETto open the server-to-client SSE channel, and that stream stays open for the whole session. The per-client gate counted it as an in-flight request, so every connected session permanently held one of theMCP_MAX_CONCURRENT_REQUESTSslots (default 4) — the fifth session got429 Too many concurrent MCP requestsoninitializewhile the hub was otherwise idle, and stayed locked out until the older sessions ended. Since sessions of the same editor or CLI share one OAuth client, running a handful of them was enough. Listening streams now have their own budget.
Added
MCP_MAX_CONCURRENT_STREAMS(default 32) bounds the SSE listening streams one OAuth client may hold open, so the stream budget stays limited without competing with actual request work.
Security
- The image overwrites npm's vendored
tarwith 7.5.22, alongside thebrace-expansionandip-addressreplacements it already carried. npm 12.0.2 still pins 7.5.19, which CVE-2026-73566 (denial of service via a crafted long path) applies to.
[0.9.1] - 2026-08-20
Fixed
npx @ni-c/mcp-hubdid nothing. npm links abinentry asnode_modules/.bin/<name>— a symlink whose basename is the command, not the file — and the entry point recognised itself by comparing that basename with its own file name. Started through the symlink it therefore never ran: the process exited 0 without a listener, a child or a single log line, in HTTP as well as in stdio mode. Onlynode dist/index.js(what the container does) ever worked. Entry-point detection now compares real paths, and a test starts the hub through a.bin-style symlink.
[0.9.0] - 2026-08-20
Added
- On-demand servers. Stdio and docker servers now start when they are used and go to sleep after
IDLE_TIMEOUT_MINUTES(default 60) without a forwarded request — on a small host, a dozen configured servers cost only the memory of the ones actually in use. While a server sleeps,initializeandtools/listare answered from a persistent snapshot (TOOL_CACHE_PATH, default/data/tool-cache.json), so a client enumerating its connectors wakes nothing; the first real tool call wakes the server and blocks until it is up (120 s budget)./hub'slist_toolsandget_tool_schemaanswer from the snapshot and pre-warm the server in the background. Per-server control:"keepAlive": truekeeps a server always running (the previous behaviour),"idleMinutes"overrides the global timeout;IDLE_TIMEOUT_MINUTES=0disables the feature entirely. New/hubmeta-toolswake_serverandsleep_serversteer the lifecycle manually. An on-demand server that crashes five restarts in a row without being used is parked assleeping(error kept visible) instead of restarting forever./healthtreatssleepingas healthy. The docker-proxy and its policy are unchanged —DOCKER_POLICY_VERSIONstays at 1. hub: falseservers are lifecycle-managed through/hub.list_serversnow includes them with ahiddenmarker andwake_server/sleep_serveraccept them — hiding a server's tools no longer means its lifecycle can only be reached by the idle sweep. Tool access (list_tools,get_tool_schema,call_tool) still refuses hidden servers, now pointing at the server's own endpoint instead of pretending it does not exist.- Single-file config mounts log a startup warning. A
-v ./mcp.json:/config/mcp.jsonbind mount silently loses every editor save that goes through a rename (new inode), killing hot reload. The hub and the docker-proxy now detect that setup via/proc/self/mountinfoand say so at startup. - stdio mode.
mcp-hub --stdio(or themcp-hub-stdiobinary) serves the/hubaggregate — the same six meta-tools, the samemcp.json, the same supervision, on-demand lifecycle and hot reload — on stdin/stdout, for clients that can only spawn a local process. No listener, no OAuth, noEXTERNAL_URL; the trust boundary is the local user account.CONFIG_PATHdefaults tomcp.jsonin the working directory, the tool cache to.mcp-hub/tool-cache.jsonbeside it, and a missing config starts an empty hub instead of failing, because a client-spawned process has nowhere to show a startup error.console.log/console.infoare moved to stderr for the life of the process: stdout carries the protocol. The MCP Registry entry follows: the npm package is now listed as a stdio package (npx @ni-c/mcp-hub --stdio), so the hub can be installed straight from the registry. The OCI package staysstreamable-http— that is the container deployment.
Changed
- Examples and docs mount the config directory, not the file. The recommended layout is
./config/mcp.jsonmounted as./config:/config:roin both the hub and the docker-proxy — rename-style editor saves then hot-reload correctly.CONFIG_PATHand its default/config/mcp.jsonare unchanged, so existing single-file deployments keep working (with the warning above).
Fixed
- The release workflow now has a concurrency group and skips an npm publish, MCP Registry publish or GitHub release that already exists. A tag push delivered twice used to start two releases, and the loser died on npm's 403 for an already-published version — a permanently red check on a commit that is also main's HEAD. Every publishing step is now idempotent, so a re-run can finish the half that is missing; the manual
mcp-registryworkflow carries the same guard.
[0.8.0] - 2026-08-18
Added
- Secrets hot-reload. The docker-proxy now watches the sandbox secrets directory: when the content of a referenced
<set>.envchanges, it stops the affected sandbox container (after the same daemon-side ownership check as every other container action), and the hub's supervisor recreates it — the replacement create reads the file fresh. Rotating a token is now an edit, not a hub restart. Content is compared by parsed entries, so atouchor a comment-only edit triggers nothing; a broken edit (permissions, symlink, parse error, a key colliding with the entry'senv) is logged and ignored so it cannot crash-loop a running server. Opt out withSANDBOX_SECRETS_WATCH=false. The hub is unchanged andDOCKER_POLICY_VERSIONstays at 1 — 0.7.0 hubs interoperate.
[0.7.0] - 2026-08-18
Added
Sandboxed servers. An MCP server that only speaks stdio can now run in its own container without an HTTP listener, a bearer token or a bridge process in its image. Two new kinds carry the protocol on a plain byte stream, using the stdio framing the specification asks custom transports to reuse:
type: "docker"— the hub creates the container over the Docker API, attaches to its stdin/stdout and speaks MCP across the container boundary. The sandbox is described inmcp.json: image, mounts, ports, network, memory, pids, tmpfs, user. Capabilities are always dropped,no-new-privilegesis always set, there is never a restart policy, and the default network isnone.type: "unix"/type: "tcp"— the hub connects to a socket a container you started is listening on. Costs the hub no privileges at all, and a Unix socket in a shared volume reaches a sandbox running withnetwork_mode: none— which no HTTP upstream can do, because HTTP needs an interface.
Supervision is unchanged for both: ping, backoff restart, hot reload,
/hub,/health. A sandbox's stderr is prefixed and passed through exactly like a stdio child's.mcp-hub-docker-proxy(ghcr.io/ni-c/mcp-hub-docker-proxy, published from the same pipeline under the same tags). The hub is exposed to the internet and the Docker API is root-equivalent, so the hub never gets the daemon socket: this second, much smaller image holds it and enforces a policy read from the samemcp.json. It allows only containers namedmcp-sandbox-<server>for a configuredtype: "docker"entry, compares the whole create request against one rebuilt by the same function the hub used to build it — so the policy cannot drift from the code that sends the request — and refusesPrivileged,CapAdd,Devices,Mounts, host namespaces and binds under/,/proc,/sys,/dev,/etc,/boot,/root,/run,/var/runand/var/lib/dockerregardless of what the config says. Nothing is forwarded verbatim: every allowed request is rebuilt from the decision, so a duplicate query parameter or an extra JSON key has nothing to ride on.secretsFromkeeps a sandbox's credentials out of the hub entirely. The config names an env file the proxy holds; the proxy appends those variables after it has validated the create request. They never enter the process whose stdio children can read/proc/1/environ./healthnow reports each server'skind, and for a sandbox theimageandcontainerit runs as.Sandbox containers are taken out of any Compose project their image carried. An image built with
docker compose buildis stamped with that project, a container inherits its image's labels, anddocker compose downin the directory the image was built in would then collect a container the hub owns and is holding the stdio of.cpusfortype: "docker"entries. Fractional values are accepted and become Docker'sNanoCpus.MCP_CALL_TIMEOUT_MSandMCP_RESET_TIMEOUT_ON_PROGRESSfor deployments whose tools genuinely run longer than the new absolute deadline below. An unusable value logs and keeps the default instead of ending the process: unlike the other limits these are read by the request path, not at startup.
Changed
- Node 22 or newer is required (
engineswas>=20); CI runs 22 and 24 and the images are built on Node 24. Node 20 left maintenance in April 2026. DOCKER_HOSTis required fortype: "docker"entries and must point at the policy proxy. It has no default any more, and a value resolving to/var/run/docker.sockis refused outright: the hub faces the internet and the daemon API is root-equivalent, so falling back to it was the one mistake the documentation could not prevent. Hub and proxy also complete a versioned handshake before the first container operation, which fails closed against an unreachable daemon, a foreign socket or a proxy speaking a different policy.- Sandboxes now have resource limits by default:
memory512m,pidsLimit256,cpus1. Previously an entry without those fields ran unbounded. An existing sandbox that needs more must say so inmcp.json. - Tool calls have an absolute five-minute deadline. Progress notifications no longer extend it, because a child emitting one every few seconds could hold a request — and one of the client's concurrency slots — open indefinitely. Raise
MCP_CALL_TIMEOUT_MS, or setMCP_RESET_TIMEOUT_ON_PROGRESS=trueto restore the old behaviour, and raiseHTTP_REQUEST_TIMEOUT_MSand the reverse proxy with it. - For
type: "docker"entries onlyenvvalues may use${VAR}. The image, mounts, ports, network, user and command must be literal: the proxy validates those fields against the config and deliberately holds none of the hub's secrets, so a variable there would be a field it could not check. - A
type: "docker"image given as a mutable tag logs a warning at startup and on every config reload. Digests are strongly recommended; tags stay supported. - Base images are pinned to
node:24-bookworm-slimby digest.
Fixed
- The login and consent pages name the client's redirect origin in their
form-action, so signing in actually completes. Browsers apply the directive to every hop of a form submission, and the last hop is the redirect that carries the authorization code back to the client — with a bare'self'Chrome and Firefox blocked it silently, leaving the window sitting on the password prompt with nothing happening on click or Enter. The origin comes from the redirect_uri the SDK has already matched against the client's registration, so the widening is per-request and never wider than the flow.
Security
- The proxy verifies container ownership with the daemon before every start, stop, wait, attach and remove: both the
io.mcp-hub.ownerand theio.mcp-hub.serverlabel must match exactly. The name pattern alone said nothing about who created a container that happened to be calledmcp-sandbox-<server>. - Secret files are validated when the proxy starts and on every config reload, not first when a container is created — an operator finds out about a world-readable credential immediately instead of at the next restart. They must be regular non-symlink files of at most 64 KiB, mode 640 or stricter, with at most 100 unique variables and no NUL bytes or duplicate keys. A reload that references an invalid set keeps the previous policy.
- Responses from a child server are bounded: 8 MiB per forwarded result, and tool discovery stops at 100 pages, 10,000 tools, 16 MiB of metadata or a repeated pagination cursor. A server that answers
tools/listforever can no longer exhaust the hub's memory. - A Docker attach frame with an impossible length ends the stream instead of being skipped, so a desynchronised sandbox is restarted through the normal supervisor backoff rather than left attached and mute.
state.jsonmutations are serialized with a cross-process lock (0.6.2 reduced the window; it did not close it). The lock is broken only when its owner is demonstrably gone — a dead pid, or, when the owner cannot be identified at all, an age of more than 30 seconds. A state file deleted underneath a running hub is rewritten rather than turned into a permanent failure to mutate.- Release tags must match
package.jsonand point at a commit reachable frommainbefore anything is published; the MCP registry publisher is pinned by version and SHA-256 instead of being taken fromlatest; a Trivy secret scan gates every push; the docs deploy runs in a separate job so only it holds write permission; and Dependabot auto-merge is limited to patch updates of direct development dependencies.
[0.6.4] - 2026-08-18
Fixed
- The architecture diagram no longer depends on the reader's operating system. It carried a
prefers-color-schemeblock, which resolves against the OS rather than the theme toggle of GitHub or npm — so dark-mode readers on a light OS got the light artwork on a dark page. The README now uses<picture>, which is resolved against the page, and the<img>that npm falls back to brings its own card instead of a media query.
Changed
- The diagram is generated from a single source,
docs/assets/architecture.source.svg, bynpm run assets. The four rendered copies had already drifted apart; CI now fails if one of them is edited by hand. docs/public/og.pngis generated at exactly 1280x640, GitHub's recommended size for a social preview, instead of being drawn by hand.
[0.6.3] - 2026-08-17
Fixed
- The per-server proxy no longer advertises resource subscriptions it cannot serve. It passed the child's capabilities through unchanged, so a server that supports
resources/subscribemade the hub claim it too — while the proxy registers no handler for it, and the call failed with-32601. Only thesubscribeflag is dropped: listing, templates and reading are unaffected.
Changed
- Known gaps: the entry claiming
RESOURCE_BOUND_TOKENSstill defaults tofalseis gone. Resource binding has been the default since 0.5.0, and every other document already said so — this was the one place still describing the old behaviour and promising a flip that had already happened. - Known gaps now state that
listChangedis announced but never sent. Passing it on is deliberate: delivering server-initiated messages needs the per-client session state the stateless transport exists to avoid, and a client waiting for a notification that never comes is no worse off than one that was never told.
[0.6.2] - 2026-08-17
Fixed
- Admin CLI changes now reach a running hub.
state.jsonhas always had a second writer — everymcp-hub-admininvocation is its own process on the same volume — but eachAuthStoretrusted the copy it read at startup andpersist()rewrote the whole file. A token minted by the CLI was therefore refused by the hub as "Access token has been revoked" until the container was restarted; worse, a token or client the CLI revoked stayed valid, and the hub's next write put its stale snapshot back and resurrected it. Sincepersist()runs on every refresh-token rotation, that happened within minutes. Reads now re-read the file when it changed underneath them (inode, mtime and size), and every mutation is a read-modify-write. checkAlive()no longer leaks an unhandled rejection when a ping fails because the connection went away.onExit()had already cleared the client by the time the catch block readthis.client.close(), which throws synchronously and so slipped past the attached.catch(). Nothing was actually broken — the restart was already scheduled — but the resulting TypeError landed inLOG_FILEwith a misleading stack and buried real failures.
Security
- Revocation is now effective against a running hub, which is what the README and the security guide already promised. Both documents had described
tokens revokeas taking effect immediately while it silently did nothing unless the container was stopped first. state.jsonis written through a per-writer temporary file instead of a fixedstate.json.tmp, so two processes can no longer write into the same temporary. With an atomic rename a reader can never observe a partial file; the residual risk of concurrent writers is a lost update, not corruption. Documented in SECURITY.md.- A reload that cannot be parsed keeps the state already in memory instead of quarantining the file and starting fresh the way the constructor does. Rotating
cookieSecretunder a running hub would log out every session.
Changed
- Coverage gate:
@vitest/coverage-v8pinned to the exact vitest version, thresholds set just below the current measurement, and CI keeps the report as an artifact. The repository had no coverage tooling before. - The admin-CLI recipes in the README and the deployment guide no longer tell you to stop the container first.
jose6.2.8 -> 6.2.9.
[0.6.1] - 2026-08-14
Fixed
/healthruns through the same per-client request gate as the MCP routes. It had bearer auth and the resource check but no rate limit, so a token holder could hammer it without bound (CodeQLjs/missing-rate-limiting).
Changed
- README, package descriptions and documentation no longer present Claude Web as the only client: the hub serves ChatGPT connectors, Claude, Mistral Le Chat, Cursor, LibreChat and any other Streamable-HTTP MCP client, plus API-token access for the OpenAI, xAI and Gemini APIs. Wording only.
- README and documentation state the lightweight goal explicitly — one Node process, no database, stateless transport, multi-arch images, comfortable on a single-board computer like a Raspberry Pi — and the README's ASCII architecture sketch is replaced by the reworked SVG diagram (served from the docs site, following the OS colour scheme).
[0.6.0] - 2026-08-14
Added
- API tokens for clients that cannot do OAuth — the OpenAI Responses API, the xAI API, Gemini's
mcp_servertool and plain-header clients.mcp-hub-admin tokens create --resource <name|hub> --days <n>mints a long-lived, resource-bound token (printed once, never stored);tokens listandtokens revokemanage the records, and revocation refuses the token immediately even though its signature is still valid. DEFAULT_RESOURCE: optionally bind tokens to one chosen resource when an OAuth client sends no RFC 8707resourceparameter at all (older Codex logins, Google ADK, Gemini Enterprise) instead of refusing withinvalid_target. Tokens stay bound either way — never global.- OIDC discovery alias:
/.well-known/openid-configuration(plus the path-inserted form) serves the RFC 8414 document for clients that probe the OIDC path, as the MCP spec expects both to work. - Documentation: a client compatibility page covering OAuth clients, API clients and their per-client quirks.
Changed
- Registration plays along with ChatGPT's connector behaviour: public clients (
token_endpoint_auth_method: none) receive aclient_secretin the registration response — ChatGPT refuses its own registration without one — but the secret is not stored, so correct public clients are unaffected; and client secrets no longer expire (ChatGPT registers once per connector and never re-registers, so the SDK's 30-day default would brick the connector).
[0.5.1] - 2026-08-14
Added
- Listed in the official MCP Registry as
io.github.ni-c/mcp-hub, with both install paths — the npm package and the GHCR image — described as what they are: a Streamable-HTTP server on/hub, not a stdio process. The ownership proofs (mcpNameinpackage.json, theio.modelcontextprotocol.server.nameimage label) ship with this release, and the release workflow now publishes registry updates automatically.
[0.5.0] - 2026-08-14
Changed
Breaking: access tokens are bound to one resource by default.
RESOURCE_BOUND_TOKENSno longer has to be switched on; RFC 8707 binding is what you get without asking, and the setting only exists to turn it off. A token issued for/paperless/mcpreaches neither another server nor/hub, and an authorization request that names no resource is refused withinvalid_target.Upgrading: tokens issued before this release carry no resource and stop working, so every connector authorizes once more. To postpone that, set
RESOURCE_BOUND_TOKENS=false— it restores the old behaviour and logs a warning on every start. The default also applies tocreateHub()for programmatic use.Breaking:
/healthrequires a token for/hub. It reports the same fleet-wide view as the aggregate — every server's name, state and tool count — so a token bound to a single server no longer reads it. Unauthenticated liveness monitoring belongs on/livez, unchanged.The
uvlayer is pinned to a version tag (0.12.3) instead oflatest. The digest is unchanged, so the image content is identical; upgrades now arrive as readable version bumps rather than opaque digest churn.The documentation site builds with VitePress 2. VitePress 1 pins Vite 5, which is end-of-life and carries unfixable dev-server advisories; Vite 8 clears them. Documentation tooling is not part of the published package or image.
Added
- Documentation site at mcp-hub.ni-c.de — guides for configuration, deployment, clients and security, an architecture walkthrough, a troubleshooting FAQ and a full endpoint/meta-tool reference. Built with VitePress from
docs/, which carries its own manifest so the runtime image and the test matrix are unaffected, and published togh-pagesby.github/workflows/docs.yml.
[0.4.0] - 2026-08-13
Added
- Published on npm as
@ni-c/mcp-hub(the unscoped name belongs to an unrelated project).npx @ni-c/mcp-hubstarts the hub,mcp-hub-adminships as a second binary; Docker remains the recommended deployment. Releases are published via npm Trusted Publishing (OIDC, with provenance) from the newrelease.yml, which also creates the GitHub release from this changelog.
Changed
- The version reported by the
/hubserver and the child MCP clients is now read frompackage.jsoninstead of being hardcoded in two source files. - zod updated to v4, the runtime image moved to
node:26-bookworm-slim, and all GitHub Actions moved to their current majors (checkout v7, setup-node v7, CodeQL v4, docker/* v4/v6/v7, trivy-action 0.36).
Fixed
- The
mcp-hubbinary was missing its shebang line, so the npm-installed command would not execute on Unix.
[0.3.0] - 2026-08-13
Security-hardening release; deployment guidance moved to SECURITY.md.
Security
- Resource-bound access tokens (RFC 8707), opt-in enforcement via
RESOURCE_BOUND_TOKENS=true; access-token TTL down from 24 h to 15 min. - Offline revocation via
mcp-hub-admin clients list|revoke(revokedBeforemarker); stricter EdDSA-pinned JWT verification. - Per-IP rate limits on all auth endpoints before body parsing, a per-client request/concurrency gate for MCP traffic, 1 MB body limit after bearer auth, server header/request timeouts and browser hardening (CSP, frame denial) on the interactive pages.
/healthmoved behind bearer auth; new unauthenticated/livezliveness probe (also used by the imageHEALTHCHECK).
Supply chain
- Digest-/SHA-pinned base images and Actions, CodeQL + Trivy gates before publishing, SBOM and
mode=maxprovenance on images, Dependabot for npm, Docker and Actions; bundled npm replaced with npm 12 and its two remaining vendored CVEs patched in place;tinias PID 1,curlremoved, read-only-rootfs compose example.
[0.2.0] - 2026-08-11
Added
LOG_FILEmirrors every hub log line into a file with an ISO-8601 UTC prefix while leaving the console untouched — a stable path for fail2ban and friends (the Dockerjson-filepath changes on every recreate and thejournalddriver maps all stderr to priorityerr).
[0.1.0] - 2026-08-11
First public release: serve many stdio MCP servers from one container over Streamable HTTP — Claude-Code-style mcpServers config (1:1 copy), path-based routing (/<name>, /<name>/mcp), the /hub aggregate with four meta-tools, a built-in OAuth 2.1 authorization server (DCR, PKCE, per-client approval, rotating refresh tokens), child supervision with backoff restarts, config hot reload, native remote http/sse upstreams and multi-arch images on GHCR.
Known gaps
Not a roadmap with dates — an honest list of what is missing and why.
An upstream login is per hub, not per user. A credential the hub holds for a remote server belongs to the deployment. The hub does not act on behalf of the individual client that made the call, and there is no way to give two clients two different upstream identities.
An upstream that needs re-authorizing stays down until someone acts. That is deliberate — retrying cannot help — but it does mean an expired refresh token is an outage until mcp-hub-admin upstream login is run. There is no notification; watch for unauthorized in /health.
Upstream tokens are stored in the clear. They have to be presented, so they cannot be hashed like the hub's own refresh tokens. state.json is mode 0600 and was already a secret, but with upstream OAuth in use it holds credentials to a third party — treat the volume accordingly.
A 2025-11-25 client receives no change notifications. listChanged and resource subscriptions are carried on 2026-07-28 through subscriptions/listen, where the state is the open response rather than a session table. The older revision delivers them unsolicited on a channel the stateless transport does not keep, and resources/subscribe would require the hub to remember who asked for what. Both are therefore not advertised to a 2025 client at all, rather than announced and dropped — which is what the hub used to do.
A change made while a child is asleep is not reported as such. An on-demand server holds no connection, so nothing is watched while it naps. The subscription survives as intent and is re-established on the next wake, followed by a re-read signal for everything the client was watching. What the client cannot learn is what changed in between — only that it should look again.
Sampling is not forwarded. A child asking the hub to run a completion has nowhere to send that request; it is dropped and named in the log.
Log messages are not carried. logging/setLevel never had a handler, and is no longer advertised on either era. On 2026-07-28 the level is per-request _meta and there is no RPC to implement, but notifications/message is not relayed either.
One password, no users. There are no accounts, roles or audit trails, and none are planned — that is a different product. See Comparison.
No isolation between stdio servers. They share the hub's user by design. That is what sandboxing is for: a server you do not trust belongs in its own container, reached over the Docker API or a socket rather than as a child process. The stdio kind itself will not gain isolation — a child process in the hub's container is what it is.
A sandboxed server is recreated on every hub start. There is no reattaching to a container that is already running, so a server with a long startup pays it again after a hub restart. Reuse would mean a second code path plus drift detection, and a container whose stdio nobody holds is worse than a slow start.
The docker proxy is a single point of failure for sandboxes. If it is down, type: "docker" servers cannot start; stdio, remote and socket servers are unaffected. It is deliberately small for that reason.