@ni-c/mcp-hub 0.11.1 → 0.11.3

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.
@@ -6,12 +6,14 @@ import { MAX_TOOL_LIST_PAGES, MAX_TOOLS, MAX_TOOL_METADATA_BYTES, jsonSize } fro
6
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';
7
7
  import { SocketTransport } from './transports/socket.js';
8
8
  import { DockerTransport } from './transports/docker.js';
9
+ import { setTransportHandlers } from './transports/stream.js';
9
10
  import { DockerClient, parseSandboxDockerHost } from './sandbox/docker-client.js';
10
11
  import { UpstreamAuth, UpstreamLoginRequiredError } from './upstream/auth.js';
11
- import { credentialFingerprint } from './upstream/provider.js';
12
+ import { boundedRedirectFetch } from './upstream/redirects.js';
12
13
  import { ToolCache } from './tool-cache.js';
13
14
  import { filterTools, hasToolFilter, unmatchedPatterns } from './tool-filter.js';
14
15
  import { subscriptionsAllowed } from './subscriptions.js';
16
+ import { logSafe } from './auth/text.js';
15
17
  /**
16
18
  * Whether a failure is one a restart could fix.
17
19
  *
@@ -43,6 +45,10 @@ function eraHasPing(client) {
43
45
  /**
44
46
  * Remote upstreams get their configured headers on EVERY request via a fetch
45
47
  * wrapper — requestInit alone does not cover the SSE stream GET.
48
+ *
49
+ * Both wrappers follow a redirect only within the configured origin, and at
50
+ * most three: the platform would otherwise follow a `Location` anywhere,
51
+ * headers and body included. See `boundedRedirectFetch`.
46
52
  */
47
53
  function buildRemoteTransport(config, auth) {
48
54
  const url = new URL(config.url);
@@ -58,14 +64,15 @@ function buildRemoteTransport(config, auth) {
58
64
  ? new SSEClientTransport(url, { authProvider: auth.provider(), fetch: guarded })
59
65
  : new StreamableHTTPClientTransport(url, { authProvider: auth.provider(), fetch: guarded });
60
66
  }
61
- const fetchWithHeaders = (input, init) => {
67
+ // The headers go on inside the redirect wrapper, so every hop carries them.
68
+ const fetchWithHeaders = boundedRedirectFetch(url.origin, (input, init) => {
62
69
  const merged = new Headers(init?.headers);
63
70
  for (const [key, value] of Object.entries(headers)) {
64
71
  if (!merged.has(key))
65
72
  merged.set(key, value);
66
73
  }
67
74
  return fetch(input, { ...init, headers: merged });
68
- };
75
+ });
69
76
  if (config.transport === 'sse') {
70
77
  return new SSEClientTransport(url, { requestInit: { headers }, fetch: fetchWithHeaders });
71
78
  }
@@ -87,13 +94,15 @@ export function setDockerClient(client) {
87
94
  function sameFilter(a, b) {
88
95
  if (!a)
89
96
  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
97
  return ((a.toolsListChanged ?? false) === (b.toolsListChanged ?? false) &&
94
98
  (a.promptsListChanged ?? false) === (b.promptsListChanged ?? false) &&
95
99
  (a.resourcesListChanged ?? false) === (b.resourcesListChanged ?? false) &&
96
- uris(a) === uris(b));
100
+ subscriptionUris(a) === subscriptionUris(b));
101
+ }
102
+ /** JSON rather than a joined string: with a plain separator, ['a b'] and
103
+ * ['a', 'b'] compare equal, and the hub would skip a reconcile it owed. */
104
+ function subscriptionUris(filter) {
105
+ return JSON.stringify((filter.resourceSubscriptions ?? []).toSorted());
97
106
  }
98
107
  /** Whether a filter asks for nothing at all, in which case nothing is held upstream. */
99
108
  function emptyFilter(filter) {
@@ -187,6 +196,8 @@ export class ManagedServer {
187
196
  * kill the new child.
188
197
  */
189
198
  generation = 0;
199
+ /** True while client.connect() is in flight; see start(). */
200
+ connecting = false;
190
201
  /** The one upstream listen stream carrying this route's whole demand (modern era). */
191
202
  upstream;
192
203
  upstreamFilter;
@@ -349,14 +360,25 @@ export class ManagedServer {
349
360
  // question on, not answer it.
350
361
  inputRequired: { autoFulfill: false }
351
362
  });
352
- transport.onclose = () => this.onExit(this.exitReason(), generation);
363
+ setTransportHandlers(transport, { onclose: () => this.onExit(this.exitReason(), generation) });
364
+ // While the opening exchange runs, a close of the transport is not
365
+ // reported by onclose: the SDK closes the transport *before* connect()
366
+ // rejects, so the generic "connection closed" used to win the race and the
367
+ // rejection — which names the cause, and may carry a verdict no restart
368
+ // can fix — was thrown away by onExit's state guard. A refused redirect,
369
+ // a TLS failure or an unauthorized upstream all read as "connection
370
+ // closed" in the log. connect() always rejects once its transport is
371
+ // gone, so the catch below is the one place that reports this exit.
372
+ this.connecting = true;
353
373
  try {
354
374
  await client.connect(transport);
355
375
  }
356
376
  catch (error) {
377
+ this.connecting = false;
357
378
  this.onExit(`failed to start: ${error.message}`, generation, classifyAuthFailure(error));
358
379
  return;
359
380
  }
381
+ this.connecting = false;
360
382
  if (generation !== this.generation) {
361
383
  // sleep()/stop() ran while we were connecting; it already set the final
362
384
  // state, so this child is surplus and only needs to go away again.
@@ -371,7 +393,9 @@ export class ManagedServer {
371
393
  // The start itself opens a full idle window, so a pre-warmed server is not
372
394
  // swept away just before the tool call it was warmed for.
373
395
  this.lastUsedAt = this.startedAt;
374
- console.log(`[${this.name}] up (${this.serverInfo?.name ?? 'unknown'} ${this.serverInfo?.version ?? ''})`.trim());
396
+ // The child's declared identity, on its way into a file LOG_FILE mirrors
397
+ // and fail2ban reads: escaped and bounded like any other stranger's text.
398
+ console.log(`[${this.name}] up (${logSafe(this.serverInfo?.name ?? 'unknown', 100)} ${logSafe(this.serverInfo?.version ?? '', 40)})`.trim());
375
399
  this.resolveWakeWaiters();
376
400
  if (this.capabilities?.tools) {
377
401
  client.setNotificationHandler('notifications/tools/list_changed', () => {
@@ -444,7 +468,7 @@ export class ManagedServer {
444
468
  }
445
469
  this.reconciling = true;
446
470
  void this.runReconcile()
447
- .catch(error => console.error(`[${this.name}] could not update subscriptions: ${error.message}`))
471
+ .catch(error => console.error(`[${this.name}] could not update subscriptions: ${logSafe(error.message, 500)}`))
448
472
  .finally(() => {
449
473
  this.reconciling = false;
450
474
  if (!this.reconcileQueued)
@@ -563,7 +587,7 @@ export class ManagedServer {
563
587
  this.options.persist?.(this);
564
588
  }
565
589
  catch (error) {
566
- console.error(`[${this.name}] failed to list tools: ${error.message}`);
590
+ console.error(`[${this.name}] failed to list tools: ${logSafe(error.message, 500)}`);
567
591
  }
568
592
  }
569
593
  async checkAlive() {
@@ -599,7 +623,7 @@ export class ManagedServer {
599
623
  await client.ping({ timeout: PING_TIMEOUT_MS });
600
624
  }
601
625
  catch (error) {
602
- console.error(`[${this.name}] ping failed, restarting: ${error.message}`);
626
+ console.error(`[${this.name}] ping failed, restarting: ${logSafe(error.message, 500)}`);
603
627
  // close() triggers transport.onclose -> onExit -> restart with backoff.
604
628
  // Already-closed transports reject here; onExit has then run regardless.
605
629
  await client.close().catch(() => { });
@@ -610,6 +634,10 @@ export class ManagedServer {
610
634
  // left behind must not touch the current child.
611
635
  if (generation !== this.generation)
612
636
  return;
637
+ // A close during the opening exchange is reported by start()'s catch, with
638
+ // the reason connect() gives — see the note there.
639
+ if (this.connecting && reason === this.exitReason())
640
+ return;
613
641
  // A failed start reports twice: transport.onclose fires and start()'s catch
614
642
  // calls us as well. Without this guard the second call would overwrite
615
643
  // restartTimer without clearing it, so two children would be spawned and
@@ -629,7 +657,7 @@ export class ManagedServer {
629
657
  this.state = 'unauthorized';
630
658
  this.lastError = reason;
631
659
  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}`);
660
+ console.error(`[${this.name}] unauthorized (${logSafe(reason, 500)}); run: mcp-hub-admin upstream login ${this.name}`);
633
661
  return;
634
662
  }
635
663
  this.state = 'down';
@@ -641,12 +669,12 @@ export class ManagedServer {
641
669
  if (this.onDemand && this.restartsSinceUse > (this.options.maxUnusedRestarts ?? MAX_UNUSED_RESTARTS)) {
642
670
  // A crash-looping server nobody asks for would occupy the machine
643
671
  // forever. Give up until the next wake, which starts fresh.
644
- console.error(`[${this.name}] down (${reason}), giving up until next use after ${this.restartsSinceUse - 1} failed restarts`);
672
+ console.error(`[${this.name}] down (${logSafe(reason, 500)}), giving up until next use after ${this.restartsSinceUse - 1} failed restarts`);
645
673
  this.state = 'sleeping';
646
674
  this.rejectWakeWaiters(new Error(`Server "${this.name}" failed to start: ${reason}`));
647
675
  return;
648
676
  }
649
- console.error(`[${this.name}] down (${reason}), restarting in ${Math.round(this.backoffMs / 1000)}s`);
677
+ console.error(`[${this.name}] down (${logSafe(reason, 500)}), restarting in ${Math.round(this.backoffMs / 1000)}s`);
650
678
  this.restartTimer = setTimeout(() => {
651
679
  this.restarts++;
652
680
  void this.start();
@@ -733,7 +761,10 @@ export class UpstreamAuthRegistry {
733
761
  if (!config.oauth)
734
762
  return undefined;
735
763
  const auth = new UpstreamAuth(name, config, this.store, this.externalUrl);
736
- const fingerprint = credentialFingerprint(auth.identity);
764
+ // Manager identity includes live headers and authentication settings. The
765
+ // persisted credential fingerprint intentionally survives secret rotation;
766
+ // it must not keep the old in-memory configuration alive as well.
767
+ const fingerprint = JSON.stringify(config);
737
768
  const existing = this.managers.get(name);
738
769
  if (existing?.fingerprint === fingerprint)
739
770
  return existing.auth;
@@ -899,7 +930,7 @@ export class Supervisor {
899
930
  if (!managed || managed.state !== 'sleeping')
900
931
  continue;
901
932
  }
902
- console.log(`mcp-hub: removing orphaned sandbox container ${container.name}`);
933
+ console.log(`mcp-hub: removing orphaned sandbox container ${logSafe(container.name)}`);
903
934
  await dockerClient().removeContainer(container.name);
904
935
  }
905
936
  }
@@ -1,5 +1,5 @@
1
1
  import { buildCreateRequest, containerName } from '../sandbox/container-spec.js';
2
- import { StreamTransport } from './stream.js';
2
+ import { StreamTransport, setTransportHandlers } from './stream.js';
3
3
  /** Guards against a corrupt header turning into a multi-gigabyte allocation. */
4
4
  const MAX_FRAME_BYTES = 16 * 1024 * 1024;
5
5
  const STDOUT = 1;
@@ -15,7 +15,16 @@ const STDERR = 2;
15
15
  export class DockerFrameDecoder {
16
16
  onFrame;
17
17
  onError;
18
- buffer = Buffer.alloc(0);
18
+ /**
19
+ * The bytes of the frame in progress, as the chunks they arrived in, joined
20
+ * once the whole frame is here. One growing buffer would copy itself on every
21
+ * chunk — quadratic in the frame size, on the hub's event loop, for a size
22
+ * the peer chooses.
23
+ */
24
+ pending = [];
25
+ pendingBytes = 0;
26
+ /** The header of the frame in progress, once eight bytes have arrived. */
27
+ header;
19
28
  failed = false;
20
29
  constructor(onFrame, onError) {
21
30
  this.onFrame = onFrame;
@@ -24,25 +33,44 @@ export class DockerFrameDecoder {
24
33
  push(chunk) {
25
34
  if (this.failed)
26
35
  return;
27
- this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
36
+ if (chunk.length === 0)
37
+ return;
38
+ this.pending.push(chunk);
39
+ this.pendingBytes += chunk.length;
28
40
  for (;;) {
29
- if (this.buffer.length < 8)
30
- return;
31
- const size = this.buffer.readUInt32BE(4);
32
- if (size > MAX_FRAME_BYTES) {
33
- this.failed = true;
34
- this.onError(new Error(`docker frame of ${size} bytes exceeds the ${MAX_FRAME_BYTES} byte limit`));
35
- this.buffer = Buffer.alloc(0);
36
- return;
41
+ if (this.header === undefined) {
42
+ if (this.pendingBytes < 8)
43
+ return;
44
+ // Joining here is cheap: the pending pieces hold at most a header's
45
+ // worth of bytes plus whatever one chunk brought with it.
46
+ const joined = this.join();
47
+ const size = joined.readUInt32BE(4);
48
+ if (size > MAX_FRAME_BYTES) {
49
+ this.failed = true;
50
+ this.onError(new Error(`docker frame of ${size} bytes exceeds the ${MAX_FRAME_BYTES} byte limit`));
51
+ this.pending = [];
52
+ this.pendingBytes = 0;
53
+ return;
54
+ }
55
+ this.header = { stream: joined[0], size };
56
+ this.replace(joined.subarray(8));
37
57
  }
38
- if (this.buffer.length < 8 + size)
58
+ if (this.pendingBytes < this.header.size)
39
59
  return;
40
- const stream = this.buffer[0];
41
- const payload = this.buffer.subarray(8, 8 + size);
42
- this.buffer = this.buffer.subarray(8 + size);
43
- this.onFrame(stream, payload);
60
+ const joined = this.join();
61
+ const { stream, size } = this.header;
62
+ this.header = undefined;
63
+ this.replace(joined.subarray(size));
64
+ this.onFrame(stream, joined.subarray(0, size));
44
65
  }
45
66
  }
67
+ join() {
68
+ return this.pending.length === 1 ? this.pending[0] : Buffer.concat(this.pending);
69
+ }
70
+ replace(rest) {
71
+ this.pending = rest.length > 0 ? [rest] : [];
72
+ this.pendingBytes = rest.length;
73
+ }
46
74
  }
47
75
  /**
48
76
  * An MCP server running in its own container, spoken to over the Docker API.
@@ -92,14 +120,16 @@ export class DockerTransport {
92
120
  }
93
121
  this.stream = stream;
94
122
  const inner = new StreamTransport(stream, false);
95
- inner.onmessage = message => this.onmessage?.(message);
96
- inner.onerror = error => this.onerror?.(error);
97
- inner.onclose = () => {
98
- this.onclose?.();
99
- // Best effort: with AutoRemove the daemon usually got there first.
100
- if (!this.closing)
101
- void this.client.removeContainer(name).catch(() => { });
102
- };
123
+ setTransportHandlers(inner, {
124
+ onmessage: message => this.onmessage?.(message),
125
+ onerror: error => this.onerror?.(error),
126
+ onclose: () => {
127
+ this.onclose?.();
128
+ // Best effort: with AutoRemove the daemon usually got there first.
129
+ if (!this.closing)
130
+ void this.client.removeContainer(name).catch(() => { });
131
+ }
132
+ });
103
133
  await inner.start();
104
134
  this.inner = inner;
105
135
  const decoder = new DockerFrameDecoder((streamType, payload) => {
@@ -18,7 +18,17 @@ export class StreamTransport {
18
18
  onclose;
19
19
  onerror;
20
20
  onmessage;
21
- buffer = Buffer.alloc(0);
21
+ /**
22
+ * Bytes received since the last newline, as the chunks they arrived in.
23
+ *
24
+ * A list rather than one growing buffer: appending a chunk to a buffer copies
25
+ * the whole buffer, so a peer that sends a long line in small pieces makes
26
+ * the hub copy quadratically — ten megabytes in 4 KiB chunks cost a second
27
+ * of the event loop, on every restart the peer cared to provoke. The pieces
28
+ * are joined once, when a newline arrives, which is linear.
29
+ */
30
+ pending = [];
31
+ pendingBytes = 0;
22
32
  started = false;
23
33
  closed = false;
24
34
  reportedClose = false;
@@ -55,25 +65,40 @@ export class StreamTransport {
55
65
  * own codec still does the parsing; only the policy is local.
56
66
  */
57
67
  receive(chunk) {
58
- if (this.buffer.length + chunk.length > STDIO_DEFAULT_MAX_BUFFER_SIZE) {
68
+ if (this.pendingBytes + chunk.length > STDIO_DEFAULT_MAX_BUFFER_SIZE) {
59
69
  // A peer that keeps sending without ever writing a newline. This runs
60
70
  // inside a 'data' handler, so a throw here would reach
61
71
  // process.on('uncaughtException') and take the entire hub down — every
62
72
  // other server with it — because one sandboxed server misbehaved. The
63
73
  // stream is desynchronised anyway: report it, end this connection, let
64
74
  // the supervisor restart it.
65
- this.buffer = Buffer.alloc(0);
75
+ this.pending = [];
76
+ this.pendingBytes = 0;
66
77
  this.onerror?.(new Error(`ReadBuffer exceeded maximum size of ${STDIO_DEFAULT_MAX_BUFFER_SIZE} bytes`));
67
78
  void this.close();
68
79
  return;
69
80
  }
70
- this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
81
+ // Only the new chunk can hold the newline that completes a line; the
82
+ // pending pieces were already searched when they arrived.
83
+ if (chunk.indexOf('\n') === -1) {
84
+ this.pending.push(chunk);
85
+ this.pendingBytes += chunk.length;
86
+ return;
87
+ }
88
+ let buffer = this.pending.length === 0 ? chunk : Buffer.concat([...this.pending, chunk]);
89
+ this.pending = [];
90
+ this.pendingBytes = 0;
71
91
  for (;;) {
72
- const newline = this.buffer.indexOf('\n');
73
- if (newline === -1)
92
+ const newline = buffer.indexOf('\n');
93
+ if (newline === -1) {
94
+ if (buffer.length > 0) {
95
+ this.pending.push(buffer);
96
+ this.pendingBytes = buffer.length;
97
+ }
74
98
  return;
75
- const line = this.buffer.toString('utf8', 0, newline).replace(/\r$/, '');
76
- this.buffer = this.buffer.subarray(newline + 1);
99
+ }
100
+ const line = buffer.toString('utf8', 0, newline).replace(/\r$/, '');
101
+ buffer = buffer.subarray(newline + 1);
77
102
  let message;
78
103
  try {
79
104
  message = deserializeMessage(line);
@@ -108,8 +133,19 @@ export class StreamTransport {
108
133
  return;
109
134
  this.reportedClose = true;
110
135
  this.closed = true;
111
- this.buffer = Buffer.alloc(0);
136
+ this.pending = [];
137
+ this.pendingBytes = 0;
112
138
  this.onclose?.();
113
139
  }
114
140
  }
141
+ /**
142
+ * Installs callbacks on a transport. The MCP Transport contract has no
143
+ * listener API: `onmessage`, `onerror` and `onclose` are properties holding
144
+ * one handler each, and the SDK's own Protocol class sets them exactly this
145
+ * way. Keeping that assignment in one place says so once instead of at every
146
+ * call site.
147
+ */
148
+ export function setTransportHandlers(transport, handlers) {
149
+ Object.assign(transport, handlers);
150
+ }
115
151
  //# sourceMappingURL=stream.js.map
@@ -1,8 +1,11 @@
1
1
  import { discoverOAuthServerInfo, exchangeAuthorization, refreshAuthorization, startAuthorization } from '@modelcontextprotocol/client';
2
+ import dns from 'node:dns/promises';
3
+ import net from 'node:net';
2
4
  import { isPrivateAddress, resolvePublicAddress } from '../auth/address.js';
3
- import { guardedRequest } from '../auth/pinned-fetch.js';
5
+ import { boundedResponse, guardedRequest } from '../auth/pinned-fetch.js';
4
6
  import { logSafe } from '../auth/text.js';
5
7
  import { UpstreamAuthProvider, callbackUrl, credentialFingerprint, hubClientMetadata } from './provider.js';
8
+ import { boundedRedirectFetch } from './redirects.js';
6
9
  /**
7
10
  * Everything the hub needs to authenticate itself to one upstream MCP server.
8
11
  *
@@ -81,12 +84,13 @@ export class UpstreamAuth {
81
84
  async privateAllowed() {
82
85
  if (this.allowPrivate === undefined) {
83
86
  const hostname = new URL(this.identity.serverUrl).hostname.replace(/^\[|\]$/g, '');
84
- try {
85
- this.allowPrivate = isPrivateAddress(hostname) || (await resolvePublicAddress(hostname).then(() => false, () => true));
86
- }
87
- catch {
88
- this.allowPrivate = true;
89
- }
87
+ // isPrivateAddress accepts numeric addresses only and rejects everything
88
+ // else. Passing a DNS name to it used to enable private access for every
89
+ // named upstream. Failed or mixed DNS answers must not relax the policy.
90
+ const addresses = net.isIP(hostname) ? [{ address: hostname }] : await dns.lookup(hostname, { all: true });
91
+ if (addresses.length === 0)
92
+ throw new Error('upstream hostname resolved to no addresses');
93
+ this.allowPrivate = addresses.every(entry => isPrivateAddress(entry.address));
90
94
  }
91
95
  return this.allowPrivate;
92
96
  }
@@ -95,9 +99,13 @@ export class UpstreamAuth {
95
99
  async asFetch(input, init) {
96
100
  const url = new URL(input instanceof Request ? input.url : String(input));
97
101
  const allowPrivate = await this.privateAllowed();
102
+ if (url.username || url.password || (url.protocol !== 'https:' && !(allowPrivate && url.protocol === 'http:'))) {
103
+ throw new Error('upstream OAuth endpoints must use HTTPS (HTTP is allowed only for private upstreams) without URL credentials');
104
+ }
98
105
  const pinned = await resolvePublicAddress(url.hostname, allowPrivate);
99
106
  if (!pinned) {
100
- return fetch(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(AS_TIMEOUT_MS) });
107
+ const response = await fetch(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(AS_TIMEOUT_MS) });
108
+ return boundedResponse(response, AS_MAX_BYTES);
101
109
  }
102
110
  return guardedRequest(url, {
103
111
  pinnedAddress: pinned,
@@ -373,6 +381,9 @@ export class UpstreamAuth {
373
381
  */
374
382
  createFetch() {
375
383
  const upstreamOrigin = new URL(this.identity.serverUrl).origin;
384
+ // Data-plane redirects stay within the upstream's origin, three at most;
385
+ // the control plane refuses them outright in asFetch.
386
+ const dataPlane = boundedRedirectFetch(upstreamOrigin);
376
387
  return async (input, init) => {
377
388
  const url = new URL(input instanceof Request ? input.url : String(input));
378
389
  const isControlPlane = url.origin !== upstreamOrigin || url.pathname.startsWith('/.well-known/');
@@ -387,7 +398,7 @@ export class UpstreamAuth {
387
398
  if (!headers.has(key))
388
399
  headers.set(key, value);
389
400
  }
390
- return { response: await fetch(url, { ...init, headers }), token };
401
+ return { response: await dataPlane(url, { ...init, headers }), token };
391
402
  };
392
403
  const first = await send();
393
404
  if (first.response.status !== 401)
@@ -33,7 +33,7 @@ export function credentialFingerprint(identity) {
33
33
  const { oauth } = identity;
34
34
  return crypto
35
35
  .createHash('sha256')
36
- .update(JSON.stringify([identity.serverUrl, oauth.mode, oauth.grant, oauth.clientId ?? null, [...oauth.scopes].sort()]))
36
+ .update(JSON.stringify([identity.serverUrl, oauth.mode, oauth.grant, oauth.clientId ?? null, oauth.scopes.toSorted()]))
37
37
  .digest('hex');
38
38
  }
39
39
  export function callbackUrl(externalUrl) {
@@ -0,0 +1,86 @@
1
+ import { logSafe } from '../auth/text.js';
2
+ /**
3
+ * Redirects on the data plane of a remote upstream, followed only within the
4
+ * origin the operator configured.
5
+ *
6
+ * `fetch` follows a 3xx by default, and neither the SDK's transports nor the
7
+ * hub's own wrappers said otherwise — so a remote MCP server could answer a
8
+ * `tools/call`, the SSE stream or a `subscriptions/listen` with a `Location`
9
+ * pointing at an internal address, and the hub would connect there, send the
10
+ * JSON-RPC body and every configured header except `Authorization` and
11
+ * `Cookie` (the two the platform strips across origins), and parse whatever
12
+ * came back as MCP. The control plane — discovery, token, registration — has
13
+ * refused redirects since the guard for the authorization server was written;
14
+ * this closes the same door on the other side.
15
+ *
16
+ * Same origin is the line, not same host: a different port on the same name
17
+ * is a different service, and a plain-http twin of an https upstream is not
18
+ * the upstream. Within that line a hop is followed because servers really do
19
+ * redirect `/mcp` to `/mcp/`, and refusing it would break upstreams that were
20
+ * never a problem.
21
+ */
22
+ export const MAX_REDIRECT_HOPS = 3;
23
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
24
+ /**
25
+ * Wraps a fetch so that every redirect it would follow is checked first.
26
+ *
27
+ * `origin` is the configured upstream's origin (`new URL(config.url).origin`).
28
+ * The returned function has the platform's shape and can be handed to the SDK
29
+ * transports as their `fetch`; every hop goes through `fetchImpl`, so a wrapper
30
+ * that adds headers still adds them on each hop.
31
+ */
32
+ export function boundedRedirectFetch(origin, fetchImpl = fetch) {
33
+ return async (input, init) => {
34
+ let url = new URL(input instanceof Request ? input.url : String(input));
35
+ let request = { ...init, redirect: 'manual' };
36
+ for (let hop = 0;; hop++) {
37
+ // A caller that built a Request keeps it on the first hop — its body
38
+ // lives there. Nothing in the hub does, but the shape is the platform's.
39
+ const response = await fetchImpl(hop === 0 && input instanceof Request ? new Request(input, request) : url, request);
40
+ if (!REDIRECT_STATUSES.has(response.status))
41
+ return response;
42
+ const location = response.headers.get('location');
43
+ if (location === null)
44
+ return response;
45
+ // Nothing of the redirect's body is wanted, and holding the stream open
46
+ // would keep the connection with it.
47
+ await response.body?.cancel().catch(() => { });
48
+ let next;
49
+ try {
50
+ next = new URL(location, url);
51
+ }
52
+ catch {
53
+ throw new Error(`upstream at ${logSafe(url.origin)} redirected to an unparseable location`);
54
+ }
55
+ if (next.origin !== origin) {
56
+ throw new Error(`upstream at ${logSafe(url.origin)} redirected to ${logSafe(next.origin)} — refused, redirects are followed only within the configured origin`);
57
+ }
58
+ if (hop + 1 >= MAX_REDIRECT_HOPS) {
59
+ throw new Error(`upstream at ${logSafe(url.origin)} redirected more than ${MAX_REDIRECT_HOPS} times`);
60
+ }
61
+ // A fragment on the request URL survives a redirect that has none.
62
+ if (!next.hash && url.hash)
63
+ next.hash = url.hash;
64
+ url = next;
65
+ request = nextRequest(request, response.status);
66
+ }
67
+ };
68
+ }
69
+ /**
70
+ * What the platform would do to the method and body on this hop (Fetch
71
+ * standard, "HTTP-redirect fetch"): a 303 always becomes a GET, a 301 or 302
72
+ * turns a POST into a GET, and a 307 or 308 keeps both. The body is dropped
73
+ * whenever the method changes, with the headers that only described it.
74
+ */
75
+ function nextRequest(request, status) {
76
+ const method = (request.method ?? 'GET').toUpperCase();
77
+ const becomesGet = status === 303 ? method !== 'GET' && method !== 'HEAD' : (status === 301 || status === 302) && method === 'POST';
78
+ if (!becomesGet)
79
+ return request;
80
+ const headers = new Headers(request.headers);
81
+ for (const name of ['content-encoding', 'content-language', 'content-location', 'content-type', 'content-length']) {
82
+ headers.delete(name);
83
+ }
84
+ return { ...request, method: 'GET', body: undefined, headers };
85
+ }
86
+ //# sourceMappingURL=redirects.js.map
@@ -2,6 +2,7 @@ import { Router } from 'express';
2
2
  import { readSignedPayload } from '../auth/signed-token.js';
3
3
  import { renderPage, escapeHtml } from '../auth/page.js';
4
4
  import { logSafe } from '../auth/text.js';
5
+ import { earlyRateLimit } from '../auth/rate-limit.js';
5
6
  import { readSessionCookie } from '../auth/session.js';
6
7
  import { UPSTREAM_CALLBACK_PATH, UPSTREAM_CLIENT_METADATA_PREFIX, UpstreamAuthProvider, clientDocumentId, clientMetadataUrl, hubClientMetadata } from './provider.js';
7
8
  const page = (res, status, title, body) => {
@@ -10,6 +11,7 @@ const page = (res, status, title, body) => {
10
11
  export function createUpstreamRoutes(options) {
11
12
  const { store, registry, supervisor, watcher, externalUrl } = options;
12
13
  const router = Router();
14
+ const secure = new URL(externalUrl).protocol === 'https:';
13
15
  /**
14
16
  * One document per upstream, addressed by a derived identifier.
15
17
  *
@@ -17,28 +19,29 @@ export function createUpstreamRoutes(options) {
17
19
  * hot-reloadable, so a server can become `cimd` long after boot, and an
18
20
  * Express route cannot be added later.
19
21
  */
20
- router.get(`/${UPSTREAM_CLIENT_METADATA_PREFIX}/:id.json`, (req, res) => {
21
- void (async () => {
22
- const wanted = String(req.params.id);
23
- for (const [name, server] of watcher.current) {
24
- if (server.kind !== 'remote' || server.oauth?.mode !== 'cimd')
25
- continue;
26
- if (clientDocumentId(name, store.cookieSecret) !== wanted)
27
- continue;
28
- const identity = { serverName: name, serverUrl: server.url, oauth: server.oauth, externalUrl };
29
- const provider = new UpstreamAuthProvider(identity, store);
30
- // The document must name itself byte-for-byte or the upstream refuses it.
31
- res.json({
32
- client_id: clientMetadataUrl(externalUrl, name, store.cookieSecret),
33
- ...hubClientMetadata(identity, server.oauth.clientAuth === 'private_key_jwt' ? await provider.publicJwk() : undefined)
34
- });
35
- return;
36
- }
37
- res.status(404).json({ error: 'not_found', error_description: 'No upstream publishes a document here' });
38
- })();
22
+ // Unauthenticated, and each hit derives one HMAC per configured upstream and
23
+ // may export a public key: cheap, but not free, and nothing legitimate asks
24
+ // for it more than once per login.
25
+ router.get(`/${UPSTREAM_CLIENT_METADATA_PREFIX}/:id.json`, earlyRateLimit(15 * 60_000, 60, 600), async (req, res) => {
26
+ const wanted = String(req.params.id);
27
+ for (const [name, server] of watcher.current) {
28
+ if (server.kind !== 'remote' || server.oauth?.mode !== 'cimd')
29
+ continue;
30
+ if (clientDocumentId(name, store.cookieSecret) !== wanted)
31
+ continue;
32
+ const identity = { serverName: name, serverUrl: server.url, oauth: server.oauth, externalUrl };
33
+ const provider = new UpstreamAuthProvider(identity, store);
34
+ // The document must name itself byte-for-byte or the upstream refuses it.
35
+ res.json({
36
+ client_id: clientMetadataUrl(externalUrl, name, store.cookieSecret),
37
+ ...hubClientMetadata(identity, server.oauth.clientAuth === 'private_key_jwt' ? await provider.publicJwk() : undefined)
38
+ });
39
+ return;
40
+ }
41
+ res.status(404).json({ error: 'not_found', error_description: 'No upstream publishes a document here' });
39
42
  });
40
43
  router.get(`/${UPSTREAM_CALLBACK_PATH}`, (req, res) => {
41
- void handleCallback(req.query, req.headers.cookie, res);
44
+ return handleCallback(req.query, req.headers.cookie, res);
42
45
  });
43
46
  async function handleCallback(query, cookie, res) {
44
47
  const state = typeof query.state === 'string' ? query.state : '';
@@ -51,7 +54,7 @@ export function createUpstreamRoutes(options) {
51
54
  // Proving the browser belongs to the operator, not just to whoever ended up
52
55
  // holding the redirect. The session cookie rides along because the upstream
53
56
  // sends a top-level navigation and the cookie is SameSite=Lax.
54
- if (readSessionCookie(cookie, store.cookieSecret) === undefined) {
57
+ if (readSessionCookie(cookie, store.cookieSecret, secure) === undefined) {
55
58
  page(res, 401, 'Not signed in', 'Sign in to this hub in the same browser, then run the login again.');
56
59
  return;
57
60
  }
@@ -64,7 +67,9 @@ export function createUpstreamRoutes(options) {
64
67
  }
65
68
  if (typeof query.error === 'string') {
66
69
  console.warn(`mcp-hub: upstream login for ${logSafe(login.serverName)} was declined: ${logSafe(query.error)}`);
67
- page(res, 400, 'Authorization declined', `The upstream reported "${query.error}". Nothing was changed.`);
70
+ // An OAuth error code is a short token; anything else is a page written
71
+ // by whoever answered as the upstream, and it does not get to write ours.
72
+ page(res, 400, 'Authorization declined', `The upstream reported "${logSafe(query.error, 80)}". Nothing was changed.`);
68
73
  return;
69
74
  }
70
75
  const code = typeof query.code === 'string' ? query.code : '';