@ni-c/mcp-hub 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +619 -0
  2. package/README.md +189 -64
  3. package/dist/admin.js +232 -8
  4. package/dist/admin.js.map +1 -1
  5. package/dist/auth/address.js +89 -0
  6. package/dist/auth/address.js.map +1 -0
  7. package/dist/auth/api-tokens.js +27 -0
  8. package/dist/auth/api-tokens.js.map +1 -0
  9. package/dist/auth/cimd.js +367 -0
  10. package/dist/auth/cimd.js.map +1 -0
  11. package/dist/auth/consent-page.js +4 -7
  12. package/dist/auth/consent-page.js.map +1 -1
  13. package/dist/auth/headers.js +22 -1
  14. package/dist/auth/headers.js.map +1 -1
  15. package/dist/auth/login-page.js +4 -7
  16. package/dist/auth/login-page.js.map +1 -1
  17. package/dist/auth/oidc/adapter.js +135 -0
  18. package/dist/auth/oidc/adapter.js.map +1 -0
  19. package/dist/auth/oidc/interactions.js +187 -0
  20. package/dist/auth/oidc/interactions.js.map +1 -0
  21. package/dist/auth/oidc/mount.js +144 -0
  22. package/dist/auth/oidc/mount.js.map +1 -0
  23. package/dist/auth/oidc/provider.js +440 -0
  24. package/dist/auth/oidc/provider.js.map +1 -0
  25. package/dist/auth/oidc/quirks.js +234 -0
  26. package/dist/auth/oidc/quirks.js.map +1 -0
  27. package/dist/auth/oidc/verifier.js +121 -0
  28. package/dist/auth/oidc/verifier.js.map +1 -0
  29. package/dist/auth/page.js +22 -0
  30. package/dist/auth/page.js.map +1 -1
  31. package/dist/auth/pinned-fetch.js +129 -0
  32. package/dist/auth/pinned-fetch.js.map +1 -0
  33. package/dist/auth/protected-resource.js +41 -0
  34. package/dist/auth/protected-resource.js.map +1 -0
  35. package/dist/auth/rate-limit.js +156 -0
  36. package/dist/auth/rate-limit.js.map +1 -0
  37. package/dist/auth/redirect-uri.js +90 -0
  38. package/dist/auth/redirect-uri.js.map +1 -0
  39. package/dist/auth/registration.js +146 -0
  40. package/dist/auth/registration.js.map +1 -0
  41. package/dist/auth/session.js +43 -0
  42. package/dist/auth/session.js.map +1 -0
  43. package/dist/auth/signed-token.js +49 -0
  44. package/dist/auth/signed-token.js.map +1 -0
  45. package/dist/auth/store.js +516 -5
  46. package/dist/auth/store.js.map +1 -1
  47. package/dist/auth/text.js +51 -0
  48. package/dist/auth/text.js.map +1 -0
  49. package/dist/config.js +184 -5
  50. package/dist/config.js.map +1 -1
  51. package/dist/docker-proxy/policy.js +1 -0
  52. package/dist/docker-proxy/policy.js.map +1 -1
  53. package/dist/docker-proxy/server.js +1 -0
  54. package/dist/docker-proxy/server.js.map +1 -1
  55. package/dist/elicitation.js +0 -0
  56. package/dist/elicitation.js.map +1 -0
  57. package/dist/forward.js +0 -0
  58. package/dist/forward.js.map +1 -0
  59. package/dist/health.js +22 -3
  60. package/dist/health.js.map +1 -1
  61. package/dist/hub.js +337 -29
  62. package/dist/hub.js.map +1 -1
  63. package/dist/index.js +218 -23
  64. package/dist/index.js.map +1 -1
  65. package/dist/limits.js +13 -1
  66. package/dist/limits.js.map +1 -1
  67. package/dist/mcp-limits.js +14 -2
  68. package/dist/mcp-limits.js.map +1 -1
  69. package/dist/proxy.js +262 -34
  70. package/dist/proxy.js.map +1 -1
  71. package/dist/stdio.js +97 -7
  72. package/dist/stdio.js.map +1 -1
  73. package/dist/subscriptions.js +236 -0
  74. package/dist/subscriptions.js.map +1 -0
  75. package/dist/supervisor.js +472 -28
  76. package/dist/supervisor.js.map +1 -1
  77. package/dist/timings.js +61 -0
  78. package/dist/timings.js.map +1 -0
  79. package/dist/tool-filter.js +59 -0
  80. package/dist/tool-filter.js.map +1 -0
  81. package/dist/transports/docker.js.map +1 -1
  82. package/dist/transports/stream.js +33 -20
  83. package/dist/transports/stream.js.map +1 -1
  84. package/dist/upstream/auth.js +435 -0
  85. package/dist/upstream/auth.js.map +1 -0
  86. package/dist/upstream/login.js +94 -0
  87. package/dist/upstream/login.js.map +1 -0
  88. package/dist/upstream/provider.js +286 -0
  89. package/dist/upstream/provider.js.map +1 -0
  90. package/dist/upstream/routes.js +97 -0
  91. package/dist/upstream/routes.js.map +1 -0
  92. package/package.json +23 -7
  93. package/dist/auth/provider.js +0 -380
  94. package/dist/auth/provider.js.map +0 -1
  95. package/dist/auth/routes.js +0 -223
  96. package/dist/auth/routes.js.map +0 -1
@@ -1,21 +1,63 @@
1
- import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
- import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js';
3
- import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
4
- import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
1
+ import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/client/stdio';
2
+ import { Client, StreamableHTTPClientTransport, SSEClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
3
+ import { ListToolsResultSchema } from '@modelcontextprotocol/core';
5
4
  import { VERSION } from './version.js';
6
5
  import { MAX_TOOL_LIST_PAGES, MAX_TOOLS, MAX_TOOL_METADATA_BYTES, jsonSize } from './mcp-limits.js';
7
- import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
6
+ import { BACKOFF_INITIAL_MS, BACKOFF_MAX_MS, BACKOFF_RESET_AFTER_MS, IDLE_SWEEP_INTERVAL_MS, MAX_UNUSED_RESTARTS, PING_INTERVAL_MS, PING_TIMEOUT_MS, WAKE_TIMEOUT_MS } from './timings.js';
8
7
  import { SocketTransport } from './transports/socket.js';
9
8
  import { DockerTransport } from './transports/docker.js';
10
9
  import { DockerClient, parseSandboxDockerHost } from './sandbox/docker-client.js';
10
+ import { UpstreamAuth, UpstreamLoginRequiredError } from './upstream/auth.js';
11
+ import { credentialFingerprint } from './upstream/provider.js';
11
12
  import { ToolCache } from './tool-cache.js';
13
+ import { filterTools, hasToolFilter, unmatchedPatterns } from './tool-filter.js';
14
+ import { subscriptionsAllowed } from './subscriptions.js';
15
+ /**
16
+ * Whether a failure is one a restart could fix.
17
+ *
18
+ * Only two things mean "a human has to act": our own manager saying there is no
19
+ * usable credential, and the SDK giving up on authorization. Everything else —
20
+ * DNS, a 5xx, a timeout — is transient and must keep its backoff, or an
21
+ * upstream that would have recovered on its own would need manual attention.
22
+ */
23
+ function classifyAuthFailure(error) {
24
+ return error instanceof UpstreamLoginRequiredError || error instanceof UnauthorizedError ? 'unauthorized' : 'restart';
25
+ }
26
+ /**
27
+ * Whether this connection's protocol revision still has `ping`.
28
+ *
29
+ * `2026-07-28` removed it. Asked on that era the SDK refuses rather than
30
+ * sending anything, so a liveness probe there is not a failed ping — it is a
31
+ * question that no longer exists. Everything older has it.
32
+ *
33
+ * Read from the connection rather than from the config, because the era is not
34
+ * a property of the server we configured: it is the outcome of the opening
35
+ * exchange with the server that actually answered.
36
+ */
37
+ function eraHasPing(client) {
38
+ const era = client.getNegotiatedProtocolVersion?.();
39
+ // Undefined means the SDK has not recorded one, which is every revision that
40
+ // predates the negotiation API. Those all have ping.
41
+ return era === undefined || era < '2026-07-28';
42
+ }
12
43
  /**
13
44
  * Remote upstreams get their configured headers on EVERY request via a fetch
14
45
  * wrapper — requestInit alone does not cover the SSE stream GET.
15
46
  */
16
- function buildRemoteTransport(config) {
47
+ function buildRemoteTransport(config, auth) {
17
48
  const url = new URL(config.url);
18
49
  const headers = config.headers;
50
+ if (auth) {
51
+ // Deliberately no `requestInit`: the transport merges those headers *after*
52
+ // the OAuth Authorization header, so a static one would win — and the SDK
53
+ // would carry them to the authorization server as well, because the fetch
54
+ // it uses for tokens is the same object. The auth manager's own fetch adds
55
+ // the configured headers to upstream requests only.
56
+ const guarded = auth.createFetch();
57
+ return config.transport === 'sse'
58
+ ? new SSEClientTransport(url, { authProvider: auth.provider(), fetch: guarded })
59
+ : new StreamableHTTPClientTransport(url, { authProvider: auth.provider(), fetch: guarded });
60
+ }
19
61
  const fetchWithHeaders = (input, init) => {
20
62
  const merged = new Headers(init?.headers);
21
63
  for (const [key, value] of Object.entries(headers)) {
@@ -41,14 +83,25 @@ export function dockerClient() {
41
83
  export function setDockerClient(client) {
42
84
  sharedDockerClient = client;
43
85
  }
44
- const BACKOFF_INITIAL_MS = 1_000;
45
- const BACKOFF_MAX_MS = 5 * 60_000;
46
- const BACKOFF_RESET_AFTER_MS = 5 * 60_000;
47
- const PING_INTERVAL_MS = 60_000;
48
- const PING_TIMEOUT_MS = 30_000;
49
- const WAKE_TIMEOUT_MS = 120_000;
50
- const MAX_UNUSED_RESTARTS = 5;
51
- const IDLE_SWEEP_INTERVAL_MS = 60_000;
86
+ /** Whether two upstream listen filters ask for the same thing. */
87
+ function sameFilter(a, b) {
88
+ if (!a)
89
+ return false;
90
+ // JSON rather than a joined string: with a plain separator, ['a b'] and
91
+ // ['a', 'b'] compare equal, and the hub would skip a reconcile it owed.
92
+ const uris = (filter) => JSON.stringify([...(filter.resourceSubscriptions ?? [])].sort());
93
+ return ((a.toolsListChanged ?? false) === (b.toolsListChanged ?? false) &&
94
+ (a.promptsListChanged ?? false) === (b.promptsListChanged ?? false) &&
95
+ (a.resourcesListChanged ?? false) === (b.resourcesListChanged ?? false) &&
96
+ uris(a) === uris(b));
97
+ }
98
+ /** Whether a filter asks for nothing at all, in which case nothing is held upstream. */
99
+ function emptyFilter(filter) {
100
+ return (!filter.toolsListChanged &&
101
+ !filter.promptsListChanged &&
102
+ !filter.resourcesListChanged &&
103
+ (filter.resourceSubscriptions?.length ?? 0) === 0);
104
+ }
52
105
  export async function listAllTools(client) {
53
106
  const tools = [];
54
107
  let cursor;
@@ -58,7 +111,15 @@ export async function listAllTools(client) {
58
111
  do {
59
112
  if (++pages > MAX_TOOL_LIST_PAGES)
60
113
  throw new Error(`tools/list exceeded ${MAX_TOOL_LIST_PAGES} pages`);
61
- const page = await client.listTools({ cursor });
114
+ // `request` rather than `listTools`, deliberately. SDK v2's `listTools()`
115
+ // walks the whole pagination itself whenever no cursor is given -- which is
116
+ // every first call -- and hands back one merged result. That would collapse
117
+ // this loop into a single iteration and take all three budgets below with
118
+ // it, leaving only the SDK's own page cap and no limit on tool count or
119
+ // metadata size at all. The budgets exist because a hostile child's tool
120
+ // list is a way to exhaust the hub's memory, and with it every other
121
+ // server's availability. One page per request is what makes them apply.
122
+ const page = await client.request({ method: 'tools/list', params: { cursor } }, ListToolsResultSchema);
62
123
  metadataBytes += jsonSize(page.tools);
63
124
  if (metadataBytes > MAX_TOOL_METADATA_BYTES)
64
125
  throw new Error(`tools/list metadata exceeded ${MAX_TOOL_METADATA_BYTES} bytes`);
@@ -82,10 +143,35 @@ export class ManagedServer {
82
143
  serverInfo;
83
144
  capabilities;
84
145
  tools = [];
146
+ /**
147
+ * How many tools the filter removed, for /health, and which entries matched
148
+ * nothing. Both stay `undefined` until the server has actually listed its
149
+ * tools: a snapshot restored from tool-cache.json is *already* filtered, so
150
+ * neither can be recomputed from it — every denyTools entry would look
151
+ * unmatched. `undefined` means "not measured yet", and /health omits it
152
+ * rather than reporting a zero it did not earn.
153
+ */
154
+ toolsHidden;
155
+ filterUnmatched;
85
156
  restarts = 0;
86
157
  lastError;
87
158
  /** Last time a request was actually forwarded; the idle sweep measures from here. */
88
159
  lastUsedAt = 0;
160
+ /**
161
+ * What the clients on this server's route are listening for. Created by the
162
+ * proxy, which owns the route's handler; the supervisor only publishes into
163
+ * it and reads its merged demand.
164
+ *
165
+ * Deliberately not consulted by the idle sweep: an open subscription is an
166
+ * intent, not a reason to keep a child process running. A sleeping child
167
+ * emits nothing, and the resync on the next wake is what makes that gap
168
+ * recoverable instead of silent.
169
+ */
170
+ channel;
171
+ /** The lease book of this server's route, or nothing if no client ever asked. */
172
+ get subscriptions() {
173
+ return this.channel?.registry;
174
+ }
89
175
  backoffMs;
90
176
  startedAt = 0;
91
177
  restartTimer;
@@ -101,6 +187,13 @@ export class ManagedServer {
101
187
  * kill the new child.
102
188
  */
103
189
  generation = 0;
190
+ /** The one upstream listen stream carrying this route's whole demand (modern era). */
191
+ upstream;
192
+ upstreamFilter;
193
+ /** URIs the child was told about one at a time (2025 era). */
194
+ upstreamUris = new Set();
195
+ reconciling = false;
196
+ reconcileQueued = false;
104
197
  constructor(name, config, options = {}) {
105
198
  this.name = name;
106
199
  this.config = config;
@@ -121,7 +214,10 @@ export class ManagedServer {
121
214
  hydrate(entry) {
122
215
  this.serverInfo = entry.serverInfo;
123
216
  this.capabilities = entry.capabilities;
124
- this.tools = entry.tools;
217
+ // Filtered again although the cache fingerprint covers the whole
218
+ // ServerConfig: "managed.tools only ever holds allowed tools" should hold
219
+ // however the array arrived, including a hand-seeded tool-cache.json.
220
+ this.tools = filterTools(this.config, entry.tools);
125
221
  this.state = 'sleeping';
126
222
  }
127
223
  markUsed() {
@@ -138,6 +234,11 @@ export class ManagedServer {
138
234
  return Promise.resolve();
139
235
  if (this.state === 'stopped')
140
236
  return Promise.reject(new Error(`Server "${this.name}" is stopped`));
237
+ // Without this the request would sit in a waiter nothing can resolve until
238
+ // the wake timeout, two minutes later.
239
+ if (this.state === 'unauthorized') {
240
+ return Promise.reject(new Error(`Server "${this.name}" needs an upstream login`));
241
+ }
141
242
  const timeoutMs = this.options.wakeTimeoutMs ?? WAKE_TIMEOUT_MS;
142
243
  const promise = new Promise((resolve, reject) => {
143
244
  const waiter = {
@@ -184,7 +285,7 @@ export class ManagedServer {
184
285
  buildTransport() {
185
286
  switch (this.config.kind) {
186
287
  case 'remote':
187
- return buildRemoteTransport(this.config);
288
+ return buildRemoteTransport(this.config, this.options.auth);
188
289
  case 'socket':
189
290
  return new SocketTransport(this.config);
190
291
  case 'docker':
@@ -215,14 +316,45 @@ export class ManagedServer {
215
316
  this.stopping = false;
216
317
  this.state = 'starting';
217
318
  const generation = ++this.generation;
319
+ if (this.options.auth) {
320
+ // Getting a token is the one part of connecting that a restart cannot
321
+ // fix, so it happens first and its failure is classified separately.
322
+ try {
323
+ await this.options.auth.prepare();
324
+ }
325
+ catch (error) {
326
+ if (generation !== this.generation)
327
+ return;
328
+ this.onExit(`upstream authorization: ${error.message}`, generation, classifyAuthFailure(error));
329
+ return;
330
+ }
331
+ }
218
332
  const transport = this.buildTransport();
219
- const client = new Client({ name: 'mcp-hub', version: VERSION }, { capabilities: {} });
333
+ const client = new Client({ name: 'mcp-hub', version: VERSION }, {
334
+ // Still nothing. The hub is not the one who can answer an elicitation —
335
+ // the person at the far end is — so declaring the capability on this
336
+ // shared connection would be a promise the hub cannot keep. What it
337
+ // declares per forwarded request is a different matter.
338
+ capabilities: {},
339
+ // 'auto' probes with server/discover and falls back to the 2025
340
+ // sequence when the child does not answer it, so a legacy child
341
+ // connects exactly as before. What it buys is the modern era where the
342
+ // child supports it: there, input_required comes back as a *result*
343
+ // the hub can forward, instead of a server→client request it would
344
+ // have nowhere to put.
345
+ versionNegotiation: { mode: 'auto' },
346
+ // The client would otherwise answer an input_required itself — from
347
+ // handlers the hub never registered, so every elicitation would be
348
+ // silently declined on the caller's behalf. A gateway has to hand the
349
+ // question on, not answer it.
350
+ inputRequired: { autoFulfill: false }
351
+ });
220
352
  transport.onclose = () => this.onExit(this.exitReason(), generation);
221
353
  try {
222
354
  await client.connect(transport);
223
355
  }
224
356
  catch (error) {
225
- this.onExit(`failed to start: ${error.message}`, generation);
357
+ this.onExit(`failed to start: ${error.message}`, generation, classifyAuthFailure(error));
226
358
  return;
227
359
  }
228
360
  if (generation !== this.generation) {
@@ -242,15 +374,178 @@ export class ManagedServer {
242
374
  console.log(`[${this.name}] up (${this.serverInfo?.name ?? 'unknown'} ${this.serverInfo?.version ?? ''})`.trim());
243
375
  this.resolveWakeWaiters();
244
376
  if (this.capabilities?.tools) {
245
- client.setNotificationHandler(ToolListChangedNotificationSchema, () => void this.refreshTools());
377
+ client.setNotificationHandler('notifications/tools/list_changed', () => {
378
+ void this.refreshTools();
379
+ this.publishUpstream({ kind: 'tools_list_changed' });
380
+ });
246
381
  await this.refreshTools();
247
382
  }
248
383
  else {
249
384
  this.options.persist?.(this);
250
385
  }
386
+ this.watchUpstream(client);
387
+ // A subscription taken while this child was asleep is an intent, not a
388
+ // connection: nothing was held upstream, so it has to be re-established
389
+ // here rather than at the moment the client asked. The same is true after
390
+ // a crash — shutdown() throws the client away and every subscription with
391
+ // it. Without this a subscription is silently dead from the first nap on.
392
+ this.reconcileSubscriptions();
393
+ // And because nothing was held, the hub cannot know what changed while the
394
+ // child was gone. Telling the listeners to read everything again is the
395
+ // only honest answer.
396
+ this.subscriptions?.resync();
397
+ // The aggregate's tool list spans this child, so its listeners have the
398
+ // same gap: whatever changed while the child was away is invisible to them
399
+ // too. One re-read tells them so.
400
+ if (this.options.aggregateWantsTools?.() === true)
401
+ this.options.onUpstreamEvent?.({ kind: 'tools_list_changed' });
251
402
  this.pingTimer = setInterval(() => void this.checkAlive(), PING_INTERVAL_MS);
252
403
  this.pingTimer.unref();
253
404
  }
405
+ /** One change event to this route's listeners and to the /hub aggregate. */
406
+ publishUpstream(event) {
407
+ this.subscriptions?.publish(event);
408
+ this.options.onUpstreamEvent?.(event);
409
+ }
410
+ /**
411
+ * Route the child's change notifications to the clients waiting for them.
412
+ *
413
+ * The same four handlers serve both eras. On a 2025 connection the child
414
+ * sends these unsolicited; on 2026-07-28 they arrive on the listen stream
415
+ * that `reconcileSubscriptions` opens, and the SDK dispatches them to these
416
+ * very registrations. So the era difference is confined to *asking*, not to
417
+ * receiving — which is the whole reason this is one code path.
418
+ *
419
+ * Gated on the declared capability for the same reason the handlers below
420
+ * are only registered for what the child said it has: a handler for a
421
+ * notification the child cannot send is a claim nobody checked.
422
+ */
423
+ watchUpstream(client) {
424
+ if (this.capabilities?.prompts) {
425
+ client.setNotificationHandler('notifications/prompts/list_changed', () => this.publishUpstream({ kind: 'prompts_list_changed' }));
426
+ }
427
+ if (this.capabilities?.resources) {
428
+ client.setNotificationHandler('notifications/resources/list_changed', () => this.publishUpstream({ kind: 'resources_list_changed' }));
429
+ client.setNotificationHandler('notifications/resources/updated', notification => this.publishUpstream({ kind: 'resource_updated', uri: notification.params.uri }));
430
+ }
431
+ }
432
+ /**
433
+ * Bring what the child is told to watch in line with what the clients want.
434
+ *
435
+ * Fire-and-forget with a single-flight guard: it is called from lease
436
+ * acquire/release, which happen on the request path and must not wait for an
437
+ * upstream round trip. A change arriving mid-reconcile queues exactly one
438
+ * more pass, so the last caller's demand always wins without a backlog.
439
+ */
440
+ reconcileSubscriptions() {
441
+ if (this.reconciling) {
442
+ this.reconcileQueued = true;
443
+ return;
444
+ }
445
+ this.reconciling = true;
446
+ void this.runReconcile()
447
+ .catch(error => console.error(`[${this.name}] could not update subscriptions: ${error.message}`))
448
+ .finally(() => {
449
+ this.reconciling = false;
450
+ if (!this.reconcileQueued)
451
+ return;
452
+ this.reconcileQueued = false;
453
+ this.reconcileSubscriptions();
454
+ });
455
+ }
456
+ async runReconcile() {
457
+ const client = this.client;
458
+ const registry = this.subscriptions;
459
+ // Asleep, down, or nobody listening: there is nothing to hold. State is not
460
+ // dropped — it lives in the leases, and start() replays it.
461
+ if (!client || this.state !== 'up')
462
+ return;
463
+ if (!subscriptionsAllowed(this.config))
464
+ return;
465
+ const demand = registry?.demand() ?? { toolsListChanged: false, promptsListChanged: false, resourcesListChanged: false, uris: [] };
466
+ // The union spans both books: this route's leases and the aggregate's.
467
+ const wantsTools = demand.toolsListChanged || this.options.aggregateWantsTools?.() === true;
468
+ // A child that never advertised `subscribe` cannot be asked for resource
469
+ // updates; asking anyway earns a -32601 per URI and nothing else.
470
+ const canSubscribe = this.capabilities?.resources?.subscribe === true;
471
+ const uris = canSubscribe ? demand.uris : [];
472
+ if (client.getProtocolEra() === 'modern') {
473
+ await this.reconcileModern(client, {
474
+ toolsListChanged: wantsTools && this.capabilities?.tools?.listChanged === true,
475
+ promptsListChanged: demand.promptsListChanged && this.capabilities?.prompts?.listChanged === true,
476
+ resourcesListChanged: demand.resourcesListChanged && this.capabilities?.resources?.listChanged === true,
477
+ resourceSubscriptions: uris
478
+ });
479
+ return;
480
+ }
481
+ await this.reconcileLegacy(client, uris);
482
+ }
483
+ /**
484
+ * One listen stream carries the whole route's demand.
485
+ *
486
+ * A filter cannot be widened in place, so any change to the union means
487
+ * closing the stream and opening a new one. That is a real gap — a
488
+ * notification landing between the two is lost — which is why the reopen is
489
+ * followed by nothing: the client that just subscribed gets its resync from
490
+ * the acquire path, and the ones already listening were not affected by
491
+ * whatever the new lease added.
492
+ */
493
+ async reconcileModern(client, filter) {
494
+ if (sameFilter(this.upstreamFilter, filter))
495
+ return;
496
+ const previous = this.upstream;
497
+ this.upstream = undefined;
498
+ this.upstreamFilter = undefined;
499
+ if (previous)
500
+ await previous.close().catch(() => { });
501
+ if (emptyFilter(filter))
502
+ return;
503
+ const subscription = await client.listen(filter);
504
+ // A close() racing the await above already cleared the field; honour that
505
+ // rather than installing a stream nobody wants.
506
+ if (this.client !== client) {
507
+ await subscription.close().catch(() => { });
508
+ return;
509
+ }
510
+ this.upstream = subscription;
511
+ this.upstreamFilter = filter;
512
+ }
513
+ /**
514
+ * The 2025 era asks per URI and gets list_changed unsolicited, so only the
515
+ * resource set is negotiated — as a diff, because re-subscribing a URI the
516
+ * child already holds is a round trip that buys nothing.
517
+ */
518
+ async reconcileLegacy(client, uris) {
519
+ const wanted = new Set(uris);
520
+ for (const uri of wanted) {
521
+ if (this.upstreamUris.has(uri))
522
+ continue;
523
+ await client.subscribeResource({ uri });
524
+ this.upstreamUris.add(uri);
525
+ }
526
+ // Snapshotted before the loop, which removes from the very set it reads.
527
+ const stale = [...this.upstreamUris].filter(uri => !wanted.has(uri));
528
+ for (const uri of stale) {
529
+ await client.unsubscribeResource({ uri }).catch(() => { });
530
+ this.upstreamUris.delete(uri);
531
+ }
532
+ }
533
+ /**
534
+ * Says what the filter did, at the one moment the operator is looking: a
535
+ * filter edit bounces this server, so this prints seconds later. That is why
536
+ * an unmatched entry need not be fatal here, unlike in the ni-c servers where
537
+ * the tool names are known before anything starts.
538
+ */
539
+ reportToolFilter(upstream) {
540
+ if (!hasToolFilter(this.config))
541
+ return;
542
+ this.toolsHidden = upstream.length - this.tools.length;
543
+ this.filterUnmatched = unmatchedPatterns(this.config, upstream);
544
+ console.log(`[${this.name}] tool filter: ${this.tools.length} of ${upstream.length} tools exposed`);
545
+ for (const pattern of this.filterUnmatched) {
546
+ console.warn(`[${this.name}] tool filter: no tool matches "${pattern}"`);
547
+ }
548
+ }
254
549
  async refreshTools() {
255
550
  // Hold the client locally: onExit() clears this.client, and a paged list
256
551
  // spans awaits, so re-reading it per page could hit undefined mid-loop.
@@ -258,7 +553,13 @@ export class ManagedServer {
258
553
  if (!client)
259
554
  return;
260
555
  try {
261
- this.tools = await listAllTools(client);
556
+ // Filtered AFTER listAllTools, never inside it: that function enforces the
557
+ // MAX_TOOLS and metadata-byte budgets against the raw upstream, and
558
+ // filtering earlier would let an upstream bury payload in tools it knows
559
+ // are filtered.
560
+ const upstream = await listAllTools(client);
561
+ this.tools = filterTools(this.config, upstream);
562
+ this.reportToolFilter(upstream);
262
563
  this.options.persist?.(this);
263
564
  }
264
565
  catch (error) {
@@ -276,6 +577,24 @@ export class ManagedServer {
276
577
  const client = this.client;
277
578
  if (this.state !== 'up' || !client)
278
579
  return;
580
+ // `ping` does not exist on 2026-07-28: the revision removed it, and the SDK
581
+ // refuses to send one rather than inventing an RPC the far end never had.
582
+ // Without this check the refusal reads as a dead child and the supervisor
583
+ // restarts a server that is perfectly healthy — forever, with the backoff
584
+ // climbing. Seen in production the morning after 0.11.0 went out: two
585
+ // remote children that happened to speak the modern era flapped every
586
+ // interval while the rest of the hub was fine.
587
+ //
588
+ // Nothing replaces it. On that era liveness is the transport's business,
589
+ // which is where it always was for a stdio child: the process exits, the
590
+ // transport closes, onExit restarts it. What an HTTP child on the modern
591
+ // era loses is the *active* probe — a server that stops answering without
592
+ // closing is noticed at the next real call rather than within a minute.
593
+ // Sending some other request as a substitute heartbeat would be worse: it
594
+ // would be a call the operator never asked for, against a server that
595
+ // charges for it or logs it, every interval, forever.
596
+ if (!eraHasPing(client))
597
+ return;
279
598
  try {
280
599
  await client.ping({ timeout: PING_TIMEOUT_MS });
281
600
  }
@@ -286,7 +605,7 @@ export class ManagedServer {
286
605
  await client.close().catch(() => { });
287
606
  }
288
607
  }
289
- onExit(reason, generation = this.generation) {
608
+ onExit(reason, generation = this.generation, outcome = 'restart') {
290
609
  // A callback from a transport that sleep()/stop()/a newer start() already
291
610
  // left behind must not touch the current child.
292
611
  if (generation !== this.generation)
@@ -295,7 +614,7 @@ export class ManagedServer {
295
614
  // calls us as well. Without this guard the second call would overwrite
296
615
  // restartTimer without clearing it, so two children would be spawned and
297
616
  // one of them left unreferenced — never pinged, never stopped.
298
- if (this.state === 'down' || this.state === 'stopped' || this.state === 'sleeping')
617
+ if (this.state === 'down' || this.state === 'stopped' || this.state === 'sleeping' || this.state === 'unauthorized')
299
618
  return;
300
619
  clearInterval(this.pingTimer);
301
620
  this.client = undefined;
@@ -303,6 +622,16 @@ export class ManagedServer {
303
622
  this.state = 'stopped';
304
623
  return;
305
624
  }
625
+ if (outcome === 'unauthorized') {
626
+ // Restarting cannot help: the upstream wants a human. Sitting in a
627
+ // five-minute retry loop forever would only hammer it and hide the reason
628
+ // behind a state that looks like an ordinary outage.
629
+ this.state = 'unauthorized';
630
+ this.lastError = reason;
631
+ this.rejectWakeWaiters(new Error(`Server "${this.name}" needs an upstream login`));
632
+ console.error(`[${this.name}] unauthorized (${reason}); run: mcp-hub-admin upstream login ${this.name}`);
633
+ return;
634
+ }
306
635
  this.state = 'down';
307
636
  this.lastError = reason;
308
637
  if (this.startedAt > 0 && Date.now() - this.startedAt > BACKOFF_RESET_AFTER_MS) {
@@ -325,6 +654,20 @@ export class ManagedServer {
325
654
  this.restartTimer.unref();
326
655
  this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
327
656
  }
657
+ /**
658
+ * Try again now that a credential may exist — called after the callback route
659
+ * completed a login, or by the admin CLI's refresh. Does nothing unless the
660
+ * server is actually waiting for one.
661
+ */
662
+ reauthorize() {
663
+ if (this.state !== 'unauthorized')
664
+ return;
665
+ this.backoffMs = this.options.backoffInitialMs ?? BACKOFF_INITIAL_MS;
666
+ // onExit() ignores a report while the state is already terminal, so it has
667
+ // to be left before the next attempt can report anything.
668
+ this.state = 'down';
669
+ void this.start();
670
+ }
328
671
  async shutdown() {
329
672
  this.stopping = true;
330
673
  // Bump the generation first: the onclose this close() is about to trigger
@@ -332,6 +675,14 @@ export class ManagedServer {
332
675
  this.generation++;
333
676
  clearTimeout(this.restartTimer);
334
677
  clearInterval(this.pingTimer);
678
+ // The upstream subscription belongs to the connection that is going away.
679
+ // Forgetting it here is what makes the next start() reconcile from scratch
680
+ // instead of believing a stream it no longer holds is still open. The
681
+ // leases themselves are untouched — they are the clients' intent, and they
682
+ // outlive any one child process.
683
+ this.upstream = undefined;
684
+ this.upstreamFilter = undefined;
685
+ this.upstreamUris.clear();
335
686
  this.rejectWakeWaiters(new Error(`Server "${this.name}" is shutting down`));
336
687
  // Same local-client rule as checkAlive(). This path happens to be safe
337
688
  // today because the member access precedes the await, but Supervisor.stop()
@@ -346,6 +697,11 @@ export class ManagedServer {
346
697
  async stop() {
347
698
  await this.shutdown();
348
699
  this.state = 'stopped';
700
+ // Unlike sleep(), this server is not coming back: the route's handler holds
701
+ // open listen streams and would otherwise outlive everything it serves.
702
+ const channel = this.channel;
703
+ this.channel = undefined;
704
+ await channel?.close().catch(() => { });
349
705
  }
350
706
  /** Same teardown as stop(), but the server stays wakeable. */
351
707
  async sleep() {
@@ -356,6 +712,41 @@ export class ManagedServer {
356
712
  this.stopping = false;
357
713
  }
358
714
  }
715
+ /**
716
+ * One credential manager per server name, outliving the ManagedServer.
717
+ *
718
+ * applyDiff() throws the ManagedServer away and builds a new one for any config
719
+ * change at all — a header edit included — so a manager held there would lose
720
+ * its in-flight refresh and its single-flight guarantee whenever the config file
721
+ * was touched. Keyed by name and rebuilt only when the credential fingerprint
722
+ * actually changes.
723
+ */
724
+ export class UpstreamAuthRegistry {
725
+ store;
726
+ externalUrl;
727
+ managers = new Map();
728
+ constructor(store, externalUrl) {
729
+ this.store = store;
730
+ this.externalUrl = externalUrl;
731
+ }
732
+ for(name, config) {
733
+ if (!config.oauth)
734
+ return undefined;
735
+ const auth = new UpstreamAuth(name, config, this.store, this.externalUrl);
736
+ const fingerprint = credentialFingerprint(auth.identity);
737
+ const existing = this.managers.get(name);
738
+ if (existing?.fingerprint === fingerprint)
739
+ return existing.auth;
740
+ this.managers.set(name, { fingerprint, auth });
741
+ return auth;
742
+ }
743
+ get(name) {
744
+ return this.managers.get(name)?.auth;
745
+ }
746
+ forget(name) {
747
+ this.managers.delete(name);
748
+ }
749
+ }
359
750
  export class Supervisor {
360
751
  config;
361
752
  options;
@@ -369,15 +760,63 @@ export class Supervisor {
369
760
  get idleTimeoutMinutes() {
370
761
  return this.options.idleTimeoutMinutes ?? 0;
371
762
  }
763
+ /** The global idle timeout as one number, whichever unit the caller gave it in. */
764
+ get idleTimeoutMs() {
765
+ return this.options.idleTimeoutMs || this.idleTimeoutMinutes * 60_000;
766
+ }
767
+ /**
768
+ * What the clients on the /hub route are listening for.
769
+ *
770
+ * /hub exposes tools and nothing else, so the only change worth relaying is
771
+ * "some child's tool list moved, re-read mine". Set by the proxy, which owns
772
+ * that route's handler.
773
+ */
774
+ hubSubscriptions;
775
+ /**
776
+ * Relay a child's change event to the /hub aggregate.
777
+ *
778
+ * Only the tool list crosses: /hub does not aggregate resources or prompts,
779
+ * so a resources/list_changed from one child describes nothing a /hub client
780
+ * can read. Sending it anyway would be the same kind of empty promise the
781
+ * capability table exists to prevent.
782
+ */
783
+ onChildEvent(cfg, event) {
784
+ if (event.kind !== 'tools_list_changed')
785
+ return;
786
+ if (cfg.hub === false || !subscriptionsAllowed(cfg))
787
+ return;
788
+ this.hubSubscriptions?.publish(event);
789
+ }
790
+ /**
791
+ * Bring every child's upstream subscription in line.
792
+ *
793
+ * Called when the /hub aggregate's demand moves, because that one book is
794
+ * shared by all of them — unlike a per-server route, where only its own
795
+ * child is affected.
796
+ */
797
+ reconcileAll() {
798
+ for (const server of this.servers.values())
799
+ server.reconcileSubscriptions();
800
+ }
801
+ aggregateWantsTools(cfg) {
802
+ if (cfg.hub === false || !subscriptionsAllowed(cfg))
803
+ return false;
804
+ return this.hubSubscriptions?.demand().toolsListChanged === true;
805
+ }
372
806
  buildManaged(name, cfg) {
373
- if ((cfg.kind !== 'stdio' && cfg.kind !== 'docker') || cfg.keepAlive || this.idleTimeoutMinutes <= 0) {
374
- return new ManagedServer(name, cfg);
807
+ const onUpstreamEvent = (event) => this.onChildEvent(cfg, event);
808
+ const aggregateWantsTools = () => this.aggregateWantsTools(cfg);
809
+ if ((cfg.kind !== 'stdio' && cfg.kind !== 'docker') || cfg.keepAlive || this.idleTimeoutMs <= 0) {
810
+ const auth = cfg.kind === 'remote' ? this.options.upstreamAuth?.for(name, cfg) : undefined;
811
+ return new ManagedServer(name, cfg, { onUpstreamEvent, aggregateWantsTools, ...(auth ? { auth } : {}) });
375
812
  }
376
813
  return new ManagedServer(name, cfg, {
377
814
  onDemand: true,
378
- idleMs: (cfg.idleMinutes ?? this.idleTimeoutMinutes) * 60_000,
815
+ idleMs: cfg.idleMinutes !== undefined ? cfg.idleMinutes * 60_000 : this.idleTimeoutMs,
379
816
  wakeTimeoutMs: this.options.wakeTimeoutMs,
380
- persist: server => this.persist(server)
817
+ persist: server => this.persist(server),
818
+ onUpstreamEvent,
819
+ aggregateWantsTools
381
820
  });
382
821
  }
383
822
  persist(server) {
@@ -416,7 +855,7 @@ export class Supervisor {
416
855
  this.startIdleSweep();
417
856
  }
418
857
  startIdleSweep() {
419
- if (this.idleTimeoutMinutes <= 0 || this.sweepTimer)
858
+ if (this.idleTimeoutMs <= 0 || this.sweepTimer)
420
859
  return;
421
860
  this.sweepTimer = setInterval(() => this.sweepIdle(), this.options.sweepIntervalMs ?? IDLE_SWEEP_INTERVAL_MS);
422
861
  this.sweepTimer.unref();
@@ -477,6 +916,11 @@ export class Supervisor {
477
916
  this.servers.delete(name);
478
917
  }
479
918
  this.options.cache?.delete(name);
919
+ // Only for servers that are gone. A `changed` one keeps its manager
920
+ // unless the credential fingerprint moved, so editing a header does not
921
+ // discard a perfectly good refresh token.
922
+ if (diff.removed.includes(name))
923
+ this.options.upstreamAuth?.forget(name);
480
924
  }
481
925
  await Promise.all([...diff.added, ...diff.changed].map(name => {
482
926
  const cfg = config.get(name);