@ni-c/mcp-hub 0.9.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +619 -0
- package/README.md +189 -64
- package/dist/admin.js +232 -8
- package/dist/admin.js.map +1 -1
- package/dist/auth/address.js +89 -0
- package/dist/auth/address.js.map +1 -0
- package/dist/auth/api-tokens.js +27 -0
- package/dist/auth/api-tokens.js.map +1 -0
- package/dist/auth/cimd.js +367 -0
- package/dist/auth/cimd.js.map +1 -0
- package/dist/auth/consent-page.js +4 -7
- package/dist/auth/consent-page.js.map +1 -1
- package/dist/auth/headers.js +22 -1
- package/dist/auth/headers.js.map +1 -1
- package/dist/auth/login-page.js +4 -7
- package/dist/auth/login-page.js.map +1 -1
- package/dist/auth/oidc/adapter.js +135 -0
- package/dist/auth/oidc/adapter.js.map +1 -0
- package/dist/auth/oidc/interactions.js +187 -0
- package/dist/auth/oidc/interactions.js.map +1 -0
- package/dist/auth/oidc/mount.js +144 -0
- package/dist/auth/oidc/mount.js.map +1 -0
- package/dist/auth/oidc/provider.js +440 -0
- package/dist/auth/oidc/provider.js.map +1 -0
- package/dist/auth/oidc/quirks.js +234 -0
- package/dist/auth/oidc/quirks.js.map +1 -0
- package/dist/auth/oidc/verifier.js +121 -0
- package/dist/auth/oidc/verifier.js.map +1 -0
- package/dist/auth/page.js +22 -0
- package/dist/auth/page.js.map +1 -1
- package/dist/auth/pinned-fetch.js +129 -0
- package/dist/auth/pinned-fetch.js.map +1 -0
- package/dist/auth/protected-resource.js +41 -0
- package/dist/auth/protected-resource.js.map +1 -0
- package/dist/auth/rate-limit.js +156 -0
- package/dist/auth/rate-limit.js.map +1 -0
- package/dist/auth/redirect-uri.js +90 -0
- package/dist/auth/redirect-uri.js.map +1 -0
- package/dist/auth/registration.js +146 -0
- package/dist/auth/registration.js.map +1 -0
- package/dist/auth/session.js +43 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/auth/signed-token.js +49 -0
- package/dist/auth/signed-token.js.map +1 -0
- package/dist/auth/store.js +516 -5
- package/dist/auth/store.js.map +1 -1
- package/dist/auth/text.js +51 -0
- package/dist/auth/text.js.map +1 -0
- package/dist/config.js +184 -5
- package/dist/config.js.map +1 -1
- package/dist/docker-proxy/policy.js +1 -0
- package/dist/docker-proxy/policy.js.map +1 -1
- package/dist/docker-proxy/server.js +1 -0
- package/dist/docker-proxy/server.js.map +1 -1
- package/dist/elicitation.js +0 -0
- package/dist/elicitation.js.map +1 -0
- package/dist/forward.js +0 -0
- package/dist/forward.js.map +1 -0
- package/dist/health.js +22 -3
- package/dist/health.js.map +1 -1
- package/dist/hub.js +337 -29
- package/dist/hub.js.map +1 -1
- package/dist/index.js +218 -23
- package/dist/index.js.map +1 -1
- package/dist/limits.js +13 -1
- package/dist/limits.js.map +1 -1
- package/dist/mcp-limits.js +14 -2
- package/dist/mcp-limits.js.map +1 -1
- package/dist/proxy.js +262 -34
- package/dist/proxy.js.map +1 -1
- package/dist/stdio.js +97 -7
- package/dist/stdio.js.map +1 -1
- package/dist/subscriptions.js +236 -0
- package/dist/subscriptions.js.map +1 -0
- package/dist/supervisor.js +472 -28
- package/dist/supervisor.js.map +1 -1
- package/dist/timings.js +61 -0
- package/dist/timings.js.map +1 -0
- package/dist/tool-filter.js +59 -0
- package/dist/tool-filter.js.map +1 -0
- package/dist/transports/docker.js.map +1 -1
- package/dist/transports/stream.js +33 -20
- package/dist/transports/stream.js.map +1 -1
- package/dist/upstream/auth.js +435 -0
- package/dist/upstream/auth.js.map +1 -0
- package/dist/upstream/login.js +94 -0
- package/dist/upstream/login.js.map +1 -0
- package/dist/upstream/provider.js +286 -0
- package/dist/upstream/provider.js.map +1 -0
- package/dist/upstream/routes.js +97 -0
- package/dist/upstream/routes.js.map +1 -0
- package/package.json +23 -7
- package/dist/auth/provider.js +0 -380
- package/dist/auth/provider.js.map +0 -1
- package/dist/auth/routes.js +0 -223
- package/dist/auth/routes.js.map +0 -1
package/dist/supervisor.js
CHANGED
|
@@ -1,21 +1,63 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }, {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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:
|
|
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.
|
|
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);
|