@ni-c/mcp-hub 0.11.4 → 0.11.6

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 CHANGED
@@ -7,6 +7,50 @@ 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.6] - 2026-10-07
11
+
12
+ ### Security
13
+
14
+ - **Upstream credentials stay with the authorization server that issued
15
+ them** (GHSA-6qxp-vccf-f47h, high). An upstream names its authorization
16
+ server on every discovery, which happens at least once an hour, and the hub
17
+ refreshes, redeems codes and requests client_credentials tokens itself. A
18
+ compromised upstream could therefore name a different authorization server
19
+ and receive the refresh token, a registered or configured client secret, or
20
+ the code being redeemed. The credential record now carries the issuer it
21
+ belongs to. A discovery that names another one is refused before anything is
22
+ sent there, and the server shows `unauthorized`, with both servers named in
23
+ the log. A genuine move is accepted after a logout and a new login:
24
+ `mcp-hub-admin upstream logout <server>`. Records from 0.11.5 or earlier are
25
+ bound to the authorization server named by their cached discovery.
26
+ - `@modelcontextprotocol/client`, `core` and `server` 2.3.0 (were 2.1.0), the
27
+ SDK's own fix for the same advisory, which also binds the credentials its
28
+ flows store to their issuer. `@modelcontextprotocol/sdk` 1.32.1 (was 1.30.0),
29
+ pulled in by `@modelcontextprotocol/server-everything` for the tests only.
30
+
31
+ ## [0.11.5] - 2026-10-06
32
+
33
+ ### Security
34
+
35
+ - `proxy-addr` 2.0.8 (was 2.0.7, GHSA-jqcg-44mw-7w3h, critical). A trust
36
+ subnet written as an IPv4-mapped IPv6 block with a short prefix, such as
37
+ `::ffff:10.0.0.0/8`, matched every IPv4 address. `trustedProxies` is handed
38
+ to Express as `trust proxy`, so with such an entry any client could set its
39
+ own `req.ip` through `X-Forwarded-For` — the key of the per-caller rate limit
40
+ and the address in the consent log. Plain IPv4 notation (`10.0.0.0/8`) was
41
+ never affected.
42
+ - `source-map-js` 1.2.2 (was 1.2.1, GHSA-68fv-2mgg-jv7q). A development
43
+ dependency only; it is not in the package or the image.
44
+ - The documentation site builds with `vue` and `@vue/server-renderer` 3.5.43
45
+ (was 3.5.41, GHSA-g2v6-rqmx-r4w6). The site renders only its own Markdown at
46
+ build time; neither the package nor the image contains Vue.
47
+ - The image overwrites npm's vendored `brace-expansion` with 5.0.11 instead of
48
+ 5.0.9 and now also its vendored `undici`, with 6.28.1. npm 12.0.2 pins
49
+ `undici` 6.27.0, and the latest npm, 12.1.0, still ships 6.28.0 and
50
+ `brace-expansion` 5.0.9, which CVE-2026-19534 (`undici`) and CVE-2026-102276
51
+ and CVE-2026-102278 (`brace-expansion`) apply to. Only the npm CLI inside the
52
+ image uses these copies; the hub's own dependencies were not affected.
53
+
10
54
  ## [0.11.4] - 2026-09-24
11
55
 
12
56
  ### Security
@@ -30,6 +30,11 @@ function refuse(response, status, reason, closeConnection = false) {
30
30
  });
31
31
  response.end(body);
32
32
  }
33
+ /**
34
+ * An upgrade is answered on the raw socket, and a refused one must not leave
35
+ * the caller holding a half-open connection: write, then destroy.
36
+ */
37
+ const hangUp = (clientSocket, status, reason) => clientSocket.end(`HTTP/1.1 ${status} ${status === 403 ? 'Forbidden' : 'Bad Gateway'}\r\nConnection: close\r\nContent-Length: ${Buffer.byteLength(reason)}\r\n\r\n${reason}`, () => clientSocket.destroy());
33
38
  function readBody(request) {
34
39
  return new Promise((resolve, reject) => {
35
40
  const chunks = [];
@@ -251,16 +256,13 @@ export function createDockerProxy(options) {
251
256
  server.on('upgrade', (request, clientSocket, head) => {
252
257
  void (async () => {
253
258
  const decision = decide(request.method ?? 'GET', request.url ?? '/', undefined);
254
- // An upgrade is answered on the raw socket, and a refused one must not
255
- // leave the caller holding a half-open connection: write, then destroy.
256
- const hangUp = (status, reason) => clientSocket.end(`HTTP/1.1 ${status} ${status === 403 ? 'Forbidden' : 'Bad Gateway'}\r\nConnection: close\r\nContent-Length: ${Buffer.byteLength(reason)}\r\n\r\n${reason}`, () => clientSocket.destroy());
257
259
  if (!decision.allow || !decision.upgrade) {
258
- hangUp(decision.allow ? 403 : decision.status, decision.allow ? 'this endpoint does not support upgrades' : decision.reason);
260
+ hangUp(clientSocket, decision.allow ? 403 : decision.status, decision.allow ? 'this endpoint does not support upgrades' : decision.reason);
259
261
  return;
260
262
  }
261
263
  const ownershipError = await verifyContainer(options, decision);
262
264
  if (ownershipError) {
263
- hangUp(ownershipError.status, ownershipError.reason);
265
+ hangUp(clientSocket, ownershipError.status, ownershipError.reason);
264
266
  return;
265
267
  }
266
268
  const upstream = http.request({
@@ -297,11 +299,11 @@ export function createDockerProxy(options) {
297
299
  });
298
300
  upstream.on('response', upstreamResponse => {
299
301
  upstreamResponse.resume();
300
- hangUp(502, `docker daemon answered ${upstreamResponse.statusCode} instead of upgrading`);
302
+ hangUp(clientSocket, 502, `docker daemon answered ${upstreamResponse.statusCode} instead of upgrading`);
301
303
  });
302
304
  upstream.on('error', error => {
303
305
  console.error(`docker-proxy: upstream error on attach: ${error.message}`);
304
- hangUp(502, `docker daemon unreachable: ${error.message}`);
306
+ hangUp(clientSocket, 502, `docker daemon unreachable: ${error.message}`);
305
307
  });
306
308
  upstream.end();
307
309
  })().catch(error => {
package/dist/hub.js CHANGED
@@ -178,6 +178,41 @@ const requireAllowedTool = (managed, tool, server) => {
178
178
  console.warn(`[${managed.name}] refused "${loggableToolName(tool)}": not permitted by allowTools/denyTools`);
179
179
  return toolError(`Unknown tool "${tool}" on server "${server}". Use list_tools to see available tools.`);
180
180
  };
181
+ /**
182
+ * `hub: false` hides a server's TOOLS from the aggregate — those are meant
183
+ * to be used through the server's own endpoint. Its lifecycle is a different
184
+ * matter: wake_server/sleep_server manage hidden servers too, so the error
185
+ * points at the right door instead of pretending the server does not exist
186
+ * (/health names every server to the same token anyway).
187
+ */
188
+ const requireExposed = (managed) => {
189
+ if (managed.config.hub)
190
+ return undefined;
191
+ return toolError(`Server "${managed.name}" is not exposed through /hub — connect to its own endpoint /${managed.name}/mcp instead.`);
192
+ };
193
+ /**
194
+ * Asking about an on-demand server's tools is the strongest hint that a
195
+ * call follows, so a sleeping server is pre-warmed in the background while
196
+ * the cached snapshot answers. Only a server with nothing cached yet (first
197
+ * ever run) blocks on the start — there is nothing truthful to answer from.
198
+ * Always-running servers pass through untouched.
199
+ */
200
+ const prepare = async (managed) => {
201
+ if (!managed.onDemand)
202
+ return undefined;
203
+ if (!managed.hasSnapshot) {
204
+ try {
205
+ await managed.wake();
206
+ }
207
+ catch (error) {
208
+ return toolError(`Server "${managed.name}" failed to start: ${reason(error)}`);
209
+ }
210
+ }
211
+ else if (managed.state === 'sleeping') {
212
+ void managed.wake().catch(() => { });
213
+ }
214
+ return undefined;
215
+ };
181
216
  /**
182
217
  * The aggregate, built for the era it will serve.
183
218
  *
@@ -190,41 +225,6 @@ const requireAllowedTool = (managed, tool, server) => {
190
225
  export function buildHubServer(supervisor, secret, era) {
191
226
  const hub = new McpServer({ name: 'mcp-hub', version: VERSION });
192
227
  const findServer = (name) => supervisor.get(name);
193
- /**
194
- * `hub: false` hides a server's TOOLS from the aggregate — those are meant
195
- * to be used through the server's own endpoint. Its lifecycle is a different
196
- * matter: wake_server/sleep_server manage hidden servers too, so the error
197
- * points at the right door instead of pretending the server does not exist
198
- * (/health names every server to the same token anyway).
199
- */
200
- const requireExposed = (managed) => {
201
- if (managed.config.hub)
202
- return undefined;
203
- return toolError(`Server "${managed.name}" is not exposed through /hub — connect to its own endpoint /${managed.name}/mcp instead.`);
204
- };
205
- /**
206
- * Asking about an on-demand server's tools is the strongest hint that a
207
- * call follows, so a sleeping server is pre-warmed in the background while
208
- * the cached snapshot answers. Only a server with nothing cached yet (first
209
- * ever run) blocks on the start — there is nothing truthful to answer from.
210
- * Always-running servers pass through untouched.
211
- */
212
- const prepare = async (managed) => {
213
- if (!managed.onDemand)
214
- return undefined;
215
- if (!managed.hasSnapshot) {
216
- try {
217
- await managed.wake();
218
- }
219
- catch (error) {
220
- return toolError(`Server "${managed.name}" failed to start: ${reason(error)}`);
221
- }
222
- }
223
- else if (managed.state === 'sleeping') {
224
- void managed.wake().catch(() => { });
225
- }
226
- return undefined;
227
- };
228
228
  hub.registerTool('list_servers', {
229
229
  title: 'List MCP servers',
230
230
  description: 'List all MCP servers available through this hub, with their status. Call this first to see what is available. ' +
@@ -1,5 +1,5 @@
1
1
  import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/client/stdio';
2
- import { Client, StreamableHTTPClientTransport, SSEClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
2
+ import { AuthorizationServerMismatchError, Client, StreamableHTTPClientTransport, SSEClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
3
3
  import { ListToolsResultSchema } from '@modelcontextprotocol/core';
4
4
  import { VERSION } from './version.js';
5
5
  import { MAX_TOOL_LIST_PAGES, MAX_TOOLS, MAX_TOOL_METADATA_BYTES, jsonSize } from './mcp-limits.js';
@@ -17,13 +17,19 @@ import { logSafe } from './auth/text.js';
17
17
  /**
18
18
  * Whether a failure is one a restart could fix.
19
19
  *
20
- * Only two things mean "a human has to act": our own manager saying there is no
21
- * usable credential, and the SDK giving up on authorization. Everything else —
20
+ * Only three things mean "a human has to act": our own manager saying there is
21
+ * no usable credential, the SDK giving up on authorization, and the SDK refusing
22
+ * to present a credential to an authorization server it does not belong to —
23
+ * which a restart would only ask again. Everything else —
22
24
  * DNS, a 5xx, a timeout — is transient and must keep its backoff, or an
23
25
  * upstream that would have recovered on its own would need manual attention.
24
26
  */
25
27
  function classifyAuthFailure(error) {
26
- return error instanceof UpstreamLoginRequiredError || error instanceof UnauthorizedError ? 'unauthorized' : 'restart';
28
+ return error instanceof UpstreamLoginRequiredError ||
29
+ error instanceof UnauthorizedError ||
30
+ error instanceof AuthorizationServerMismatchError
31
+ ? 'unauthorized'
32
+ : 'restart';
27
33
  }
28
34
  /**
29
35
  * Whether this connection's protocol revision still has `ping`.
@@ -4,7 +4,7 @@ import net from 'node:net';
4
4
  import { isPrivateAddress, resolvePublicAddress } from '../auth/address.js';
5
5
  import { boundedResponse, guardedRequest } from '../auth/pinned-fetch.js';
6
6
  import { logSafe } from '../auth/text.js';
7
- import { UpstreamAuthProvider, callbackUrl, credentialFingerprint, hubClientMetadata, wellFormedOrUndefined } from './provider.js';
7
+ import { UpstreamAuthProvider, callbackUrl, credentialFingerprint, hubClientMetadata, sameIssuer, wellFormedOrUndefined } from './provider.js';
8
8
  import { boundedRedirectFetch } from './redirects.js';
9
9
  /**
10
10
  * Everything the hub needs to authenticate itself to one upstream MCP server.
@@ -38,6 +38,11 @@ const EXPIRY_SKEW_S = 60;
38
38
  const DISCOVERY_TTL_S = 3600;
39
39
  const AS_MAX_BYTES = 256 * 1024;
40
40
  const AS_TIMEOUT_MS = 10_000;
41
+ /** The authorization server a discovery names, identified the way the SDK
42
+ * stamps credentials: the issuer its validated metadata declares. */
43
+ function issuerOf(discovery) {
44
+ return discovery.authorizationServerMetadata?.issuer ?? String(discovery.authorizationServerUrl);
45
+ }
41
46
  export class UpstreamAuth {
42
47
  config;
43
48
  store;
@@ -132,10 +137,38 @@ export class UpstreamAuth {
132
137
  if (!force && cached?.authorizationServerUrl && now - cached.fetchedAt < DISCOVERY_TTL_S)
133
138
  return cached;
134
139
  const info = await discoverOAuthServerInfo(this.identity.serverUrl, { fetchFn: this.boundAsFetch });
140
+ const bound = this.boundIssuer();
141
+ const named = issuerOf(info);
142
+ if (bound !== undefined && !sameIssuer(bound, named)) {
143
+ // Refused before anything is saved, so the cached discovery — and with it
144
+ // the revocation endpoint `upstream logout` uses — still names the
145
+ // server the credentials belong to.
146
+ throw new UpstreamLoginRequiredError(this.identity.serverName, `the upstream now names a different authorization server (${logSafe(named)}) than the one its credentials belong to (${logSafe(bound)}); if that move is intended, run "mcp-hub-admin upstream logout ${logSafe(this.identity.serverName)}" and log in again`);
147
+ }
135
148
  const discovery = { ...info, fetchedAt: now };
136
149
  this.provider().saveDiscoveryState(discovery);
137
150
  return discovery;
138
151
  }
152
+ /**
153
+ * The authorization server the stored credentials belong to, or undefined
154
+ * while nothing is stored that another one could be handed.
155
+ *
156
+ * The upstream names its authorization server on every discovery, and
157
+ * `refreshAuthorization`, `exchangeAuthorization` and the client_credentials
158
+ * request below go wherever it points — the SDK's own SEP-2352 check covers
159
+ * none of these direct calls (GHSA-6qxp-vccf-f47h). A record written before
160
+ * the stamp existed is bound to the authorization server its cached
161
+ * discovery names: the one these credentials have been used with all along.
162
+ */
163
+ boundIssuer() {
164
+ const record = this.record;
165
+ if (!record || (record.tokens === undefined && record.clientId === undefined))
166
+ return undefined;
167
+ if (record.issuer)
168
+ return record.issuer;
169
+ const cached = record.discovery;
170
+ return cached?.authorizationServerUrl ? issuerOf(cached) : undefined;
171
+ }
139
172
  /**
140
173
  * The client_id this hub uses with this upstream, registering dynamically the
141
174
  * first time when that is the configured mode.
@@ -154,11 +187,11 @@ export class UpstreamAuth {
154
187
  throw new UpstreamLoginRequiredError(this.identity.serverName, 'the upstream does not accept client ID metadata documents; use mode "dcr" or "static"');
155
188
  }
156
189
  const clientId = this.provider().clientMetadataUrl;
157
- this.provider().saveClientInformation({ client_id: clientId });
190
+ this.provider().saveClientInformation({ client_id: clientId }, { issuer: issuerOf(discovery) });
158
191
  return { client_id: clientId };
159
192
  }
160
193
  const registered = await this.registerDynamically(discovery);
161
- this.provider().saveClientInformation(registered);
194
+ this.provider().saveClientInformation(registered, { issuer: issuerOf(discovery) });
162
195
  console.log(`mcp-hub: registered with ${logSafe(discovery.authorizationServerUrl)} for upstream "${logSafe(this.identity.serverName)}"`);
163
196
  return registered;
164
197
  }
@@ -238,7 +271,7 @@ export class UpstreamAuth {
238
271
  if (this.identity.oauth.grant === 'client_credentials') {
239
272
  const tokens = await this.fetchClientCredentialsTokens(discovery, clientInformation, resource);
240
273
  try {
241
- this.provider().saveTokens(tokens);
274
+ this.provider().saveTokens(tokens, { issuer: issuerOf(discovery) });
242
275
  }
243
276
  catch (error) {
244
277
  // Same failure class as a refused refresh below: a human has to act,
@@ -261,7 +294,7 @@ export class UpstreamAuth {
261
294
  addClientAuthentication: this.provider().addClientAuthentication,
262
295
  fetchFn: this.boundAsFetch
263
296
  });
264
- this.provider().saveTokens(tokens);
297
+ this.provider().saveTokens(tokens, { issuer: issuerOf(discovery) });
265
298
  }
266
299
  catch (error) {
267
300
  // A refresh token the upstream will not honour cannot be recovered from
@@ -329,6 +362,12 @@ export class UpstreamAuth {
329
362
  /** Redeems the code the upstream sent back. */
330
363
  async finishLogin(login, code) {
331
364
  const discovery = await this.discover();
365
+ // The code and its verifier belong to the server the login started at; a
366
+ // discovery that has moved since must not receive them, or the client
367
+ // secret that goes with them.
368
+ if (!sameIssuer(login.authorizationServerUrl, String(discovery.authorizationServerUrl))) {
369
+ throw new UpstreamLoginRequiredError(this.identity.serverName, 'the upstream named a different authorization server while the login was open; start the login again');
370
+ }
332
371
  const clientInformation = await this.clientInformation(discovery);
333
372
  const tokens = await exchangeAuthorization(login.authorizationServerUrl, {
334
373
  metadata: discovery.authorizationServerMetadata,
@@ -340,7 +379,7 @@ export class UpstreamAuth {
340
379
  addClientAuthentication: this.provider().addClientAuthentication,
341
380
  fetchFn: this.boundAsFetch
342
381
  });
343
- this.provider().saveTokens(tokens);
382
+ this.provider().saveTokens(tokens, { issuer: issuerOf(discovery) });
344
383
  }
345
384
  /**
346
385
  * Best effort: tell the upstream to forget the token (RFC 7009) and, for a
@@ -129,6 +129,19 @@ export function wellFormedOrUndefined(tokens) {
129
129
  return undefined;
130
130
  return tokens;
131
131
  }
132
+ /**
133
+ * Whether two issuer identifiers name the same authorization server — the
134
+ * SDK's comparison, with RFC 8414 §3.3's one tolerance: a single trailing `/`.
135
+ */
136
+ export function sameIssuer(a, b) {
137
+ return a === b || (a.endsWith('/') && a.slice(0, -1) === b) || (b.endsWith('/') && b.slice(0, -1) === a);
138
+ }
139
+ /** The issuer a credential is saved under: the SDK's stamp on the value, or
140
+ * the authorization server the caller resolved. */
141
+ function stampFrom(value, ctx) {
142
+ const issuer = value.issuer ?? ctx?.issuer;
143
+ return issuer ? { issuer } : {};
144
+ }
132
145
  export class UpstreamAuthProvider {
133
146
  identity;
134
147
  store;
@@ -176,13 +189,22 @@ export class UpstreamAuthProvider {
176
189
  return undefined;
177
190
  return clientMetadataUrl(this.identity.externalUrl, this.identity.serverName, this.store.cookieSecret);
178
191
  }
192
+ /**
193
+ * Handed back with every credential, so the SDK's own flows refuse to present
194
+ * one to a different authorization server just as UpstreamAuth does.
195
+ */
196
+ issuerStamp() {
197
+ const issuer = this.record?.issuer;
198
+ return issuer ? { issuer } : {};
199
+ }
179
200
  clientInformation() {
180
201
  // Configured credentials win: with them there is nothing to register, and
181
202
  // the SDK skips both CIMD and dynamic registration.
182
203
  if (this.identity.oauth.mode === 'static') {
183
204
  return {
184
205
  client_id: this.identity.oauth.clientId,
185
- ...(this.identity.oauth.clientSecret ? { client_secret: this.identity.oauth.clientSecret } : {})
206
+ ...(this.identity.oauth.clientSecret ? { client_secret: this.identity.oauth.clientSecret } : {}),
207
+ ...this.issuerStamp()
186
208
  };
187
209
  }
188
210
  const record = this.record;
@@ -190,11 +212,13 @@ export class UpstreamAuthProvider {
190
212
  return undefined;
191
213
  return {
192
214
  client_id: record.clientId,
193
- ...(record.clientSecret ? { client_secret: record.clientSecret } : {})
215
+ ...(record.clientSecret ? { client_secret: record.clientSecret } : {}),
216
+ ...this.issuerStamp()
194
217
  };
195
218
  }
196
- saveClientInformation(information) {
219
+ saveClientInformation(information, ctx) {
197
220
  this.patch({
221
+ ...stampFrom(information, ctx),
198
222
  clientId: information.client_id,
199
223
  ...(typeof information.client_secret === 'string' ? { clientSecret: information.client_secret } : {}),
200
224
  // RFC 7592 credentials, when the upstream issued them — what `upstream
@@ -222,16 +246,19 @@ export class UpstreamAuthProvider {
222
246
  if (!stored)
223
247
  return undefined;
224
248
  const { refresh_token: _withheld, ...rest } = stored;
225
- return rest;
249
+ return { ...rest, ...this.issuerStamp() };
226
250
  }
227
251
  /** The full pair, for the one caller that is allowed to spend it. */
228
252
  storedTokens() {
229
253
  return wellFormedOrUndefined(this.record?.tokens);
230
254
  }
231
- saveTokens(tokens) {
255
+ saveTokens(stamped, ctx) {
256
+ // The stamp lives on the record, once for tokens and registration alike.
257
+ const { issuer: _stamp, ...tokens } = stamped;
232
258
  assertWellFormedTokens(tokens);
233
259
  const expiresIn = typeof tokens.expires_in === 'number' ? tokens.expires_in : undefined;
234
260
  this.patch({
261
+ ...stampFrom(stamped, ctx),
235
262
  tokens: tokens,
236
263
  ...(expiresIn !== undefined ? { accessTokenValidUntil: Math.floor(Date.now() / 1000) + expiresIn } : {})
237
264
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ni-c/mcp-hub",
3
- "version": "0.11.4",
3
+ "version": "0.11.6",
4
4
  "description": "Serve multiple stdio MCP servers from one container: Claude-Code-style mcpServers config, path-based routing, hub meta-tools, and CIMD-first OAuth 2.1 + API tokens for ChatGPT, Claude and any Streamable-HTTP MCP client.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -78,7 +78,7 @@
78
78
  "@types/node": "^26.2.0",
79
79
  "@types/oidc-provider": "^9.11.1",
80
80
  "@types/supertest": "^7.2.1",
81
- "@vitest/coverage-v8": "5.0.1",
81
+ "@vitest/coverage-v8": "5.0.2",
82
82
  "fast-check": "^4.9.0",
83
83
  "oxlint": "^1.80.0",
84
84
  "supertest": "^7.0.0",