@ni-c/mcp-hub 0.11.2 → 0.11.4

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.
@@ -1,3 +1,4 @@
1
+ import { logSafe } from './auth/text.js';
1
2
  import { booleanEnv, nonNegativeIntegerEnv, positiveIntegerEnv } from './mcp-limits.js';
2
3
  /**
3
4
  * Carrying a child's change notifications to the client that asked for them.
@@ -71,6 +72,15 @@ export const MAX_STREAM_MS = nonNegativeIntegerEnv('MCP_SUBSCRIPTION_MAX_MS', 30
71
72
  * window would otherwise be a million map entries the hub holds for it.
72
73
  */
73
74
  const MAX_PENDING_EVENTS = 1024;
75
+ /**
76
+ * Longest `resources/updated` URI the hub holds or forwards; a longer one is
77
+ * dropped. A URI names what to re-read — nothing legitimate comes near this.
78
+ * `MAX_PENDING_EVENTS` bounds how many one window holds, this how large each
79
+ * may be, whichever transport the child sends it over.
80
+ */
81
+ export const MAX_RESOURCE_URI_BYTES = positiveIntegerEnv('MCP_SUBSCRIPTION_MAX_URI_BYTES', 8 * 1024);
82
+ /** At most one log line a minute per registry about dropped URIs. */
83
+ const OVERSIZED_URI_WARNING_INTERVAL_MS = 60_000;
74
84
  /** True unless an operator said otherwise, globally or for this server. */
75
85
  export function subscriptionsAllowed(config) {
76
86
  return SUBSCRIPTIONS_ENABLED && config.subscriptions !== 'off';
@@ -131,6 +141,7 @@ export class SubscriptionRegistry {
131
141
  nextId = 1;
132
142
  timer;
133
143
  closed = false;
144
+ lastOversizedUriWarningAt = 0;
134
145
  constructor(notifier, options = {}) {
135
146
  this.notifier = notifier;
136
147
  this.options = options;
@@ -172,6 +183,10 @@ export class SubscriptionRegistry {
172
183
  publish(event) {
173
184
  if (this.closed)
174
185
  return;
186
+ if (event.kind === 'resource_updated' && Buffer.byteLength(event.uri, 'utf8') > MAX_RESOURCE_URI_BYTES) {
187
+ this.warnOversizedUri(event.uri);
188
+ return;
189
+ }
175
190
  const debounceMs = this.options.debounceMs ?? DEBOUNCE_MS;
176
191
  if (debounceMs <= 0) {
177
192
  this.deliver(event);
@@ -236,6 +251,14 @@ export class SubscriptionRegistry {
236
251
  return;
237
252
  }
238
253
  }
254
+ /** The URI is the child's own text, so it is escaped and truncated. */
255
+ warnOversizedUri(uri) {
256
+ const now = Date.now();
257
+ if (now - this.lastOversizedUriWarningAt < OVERSIZED_URI_WARNING_INTERVAL_MS)
258
+ return;
259
+ this.lastOversizedUriWarningAt = now;
260
+ console.error(`mcp-hub: dropped a resources/updated notification whose uri exceeded ${MAX_RESOURCE_URI_BYTES} bytes: ${logSafe(uri)}`);
261
+ }
239
262
  close() {
240
263
  this.closed = true;
241
264
  clearTimeout(this.timer);
@@ -9,6 +9,7 @@ import { DockerTransport } from './transports/docker.js';
9
9
  import { setTransportHandlers } from './transports/stream.js';
10
10
  import { DockerClient, parseSandboxDockerHost } from './sandbox/docker-client.js';
11
11
  import { UpstreamAuth, UpstreamLoginRequiredError } from './upstream/auth.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';
@@ -44,6 +45,10 @@ function eraHasPing(client) {
44
45
  /**
45
46
  * Remote upstreams get their configured headers on EVERY request via a fetch
46
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`.
47
52
  */
48
53
  function buildRemoteTransport(config, auth) {
49
54
  const url = new URL(config.url);
@@ -59,14 +64,15 @@ function buildRemoteTransport(config, auth) {
59
64
  ? new SSEClientTransport(url, { authProvider: auth.provider(), fetch: guarded })
60
65
  : new StreamableHTTPClientTransport(url, { authProvider: auth.provider(), fetch: guarded });
61
66
  }
62
- 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) => {
63
69
  const merged = new Headers(init?.headers);
64
70
  for (const [key, value] of Object.entries(headers)) {
65
71
  if (!merged.has(key))
66
72
  merged.set(key, value);
67
73
  }
68
74
  return fetch(input, { ...init, headers: merged });
69
- };
75
+ });
70
76
  if (config.transport === 'sse') {
71
77
  return new SSEClientTransport(url, { requestInit: { headers }, fetch: fetchWithHeaders });
72
78
  }
@@ -180,8 +186,16 @@ export class ManagedServer {
180
186
  restartTimer;
181
187
  pingTimer;
182
188
  stopping = false;
183
- /** Failed restarts since the last real use; wake() and markUsed() reset it. */
189
+ /** Failed restarts since the server was last up or used; a start that
190
+ * reaches 'up' and markUsed() reset it, wake() does not. */
184
191
  restartsSinceUse = 0;
192
+ /**
193
+ * When a request last revived a server that had given up. The first request
194
+ * after a give-up retries at once; another one within the current backoff is
195
+ * refused, so a caller repeating it cannot restart the server faster than its
196
+ * own crashes allow. Cleared by a start that reaches 'up'.
197
+ */
198
+ lastGiveUpRetryAt = 0;
185
199
  wakeWaiters = [];
186
200
  /**
187
201
  * Invalidates callbacks of an abandoned transport: sleep() and stop() tear a
@@ -190,6 +204,8 @@ export class ManagedServer {
190
204
  * kill the new child.
191
205
  */
192
206
  generation = 0;
207
+ /** True while client.connect() is in flight; see start(). */
208
+ connecting = false;
193
209
  /** The one upstream listen stream carrying this route's whole demand (modern era). */
194
210
  upstream;
195
211
  upstreamFilter;
@@ -242,6 +258,15 @@ export class ManagedServer {
242
258
  if (this.state === 'unauthorized') {
243
259
  return Promise.reject(new Error(`Server "${this.name}" needs an upstream login`));
244
260
  }
261
+ // A server that gave up revives on request, but no faster than its backoff.
262
+ // A request inside the window fails fast with the error the give-up
263
+ // reported, rather than queueing behind an attempt that is not coming.
264
+ if (this.state === 'sleeping' && this.restartsSinceUse > 0) {
265
+ if (Date.now() - this.lastGiveUpRetryAt < this.backoffMs) {
266
+ return Promise.reject(new Error(`Server "${this.name}" failed to start: ${this.lastError}`));
267
+ }
268
+ this.lastGiveUpRetryAt = Date.now();
269
+ }
245
270
  const timeoutMs = this.options.wakeTimeoutMs ?? WAKE_TIMEOUT_MS;
246
271
  const promise = new Promise((resolve, reject) => {
247
272
  const waiter = {
@@ -256,16 +281,20 @@ export class ManagedServer {
256
281
  waiter.timer.unref();
257
282
  this.wakeWaiters.push(waiter);
258
283
  });
259
- if (this.state === 'sleeping') {
260
- this.restartsSinceUse = 0;
284
+ if (this.state === 'sleeping' && this.restartsSinceUse === 0) {
285
+ // Idle sleep or a cache-hydrated boot: no crash history, wake at once.
261
286
  this.backoffMs = this.options.backoffInitialMs ?? BACKOFF_INITIAL_MS;
262
287
  void this.start();
263
288
  }
264
- else if (this.state === 'down') {
265
- // Someone is asking — no point in sitting out the rest of the backoff.
266
- clearTimeout(this.restartTimer);
289
+ else if (this.state === 'sleeping') {
290
+ // Given up earlier: this is the one attempt the window above allows.
291
+ // The backoff and the give-up count carry on from where the crashes
292
+ // left them instead of starting over.
267
293
  void this.start();
268
294
  }
295
+ // 'down': a restart is already scheduled by the backoff. The caller waits
296
+ // for it; cancelling the timer to start now would let requests set the
297
+ // pace of a crash loop.
269
298
  // 'starting': the in-flight start resolves the waiter.
270
299
  return promise;
271
300
  }
@@ -353,13 +382,24 @@ export class ManagedServer {
353
382
  inputRequired: { autoFulfill: false }
354
383
  });
355
384
  setTransportHandlers(transport, { onclose: () => this.onExit(this.exitReason(), generation) });
385
+ // While the opening exchange runs, a close of the transport is not
386
+ // reported by onclose: the SDK closes the transport *before* connect()
387
+ // rejects, so the generic "connection closed" used to win the race and the
388
+ // rejection — which names the cause, and may carry a verdict no restart
389
+ // can fix — was thrown away by onExit's state guard. A refused redirect,
390
+ // a TLS failure or an unauthorized upstream all read as "connection
391
+ // closed" in the log. connect() always rejects once its transport is
392
+ // gone, so the catch below is the one place that reports this exit.
393
+ this.connecting = true;
356
394
  try {
357
395
  await client.connect(transport);
358
396
  }
359
397
  catch (error) {
398
+ this.connecting = false;
360
399
  this.onExit(`failed to start: ${error.message}`, generation, classifyAuthFailure(error));
361
400
  return;
362
401
  }
402
+ this.connecting = false;
363
403
  if (generation !== this.generation) {
364
404
  // sleep()/stop() ran while we were connecting; it already set the final
365
405
  // state, so this child is surplus and only needs to go away again.
@@ -374,6 +414,9 @@ export class ManagedServer {
374
414
  // The start itself opens a full idle window, so a pre-warmed server is not
375
415
  // swept away just before the tool call it was warmed for.
376
416
  this.lastUsedAt = this.startedAt;
417
+ // Coming up is what clears the crash history — being asked for does not.
418
+ this.restartsSinceUse = 0;
419
+ this.lastGiveUpRetryAt = 0;
377
420
  // The child's declared identity, on its way into a file LOG_FILE mirrors
378
421
  // and fail2ban reads: escaped and bounded like any other stranger's text.
379
422
  console.log(`[${this.name}] up (${logSafe(this.serverInfo?.name ?? 'unknown', 100)} ${logSafe(this.serverInfo?.version ?? '', 40)})`.trim());
@@ -615,6 +658,10 @@ export class ManagedServer {
615
658
  // left behind must not touch the current child.
616
659
  if (generation !== this.generation)
617
660
  return;
661
+ // A close during the opening exchange is reported by start()'s catch, with
662
+ // the reason connect() gives — see the note there.
663
+ if (this.connecting && reason === this.exitReason())
664
+ return;
618
665
  // A failed start reports twice: transport.onclose fires and start()'s catch
619
666
  // calls us as well. Without this guard the second call would overwrite
620
667
  // restartTimer without clearing it, so two children would be spawned and
@@ -648,6 +695,9 @@ export class ManagedServer {
648
695
  // forever. Give up until the next wake, which starts fresh.
649
696
  console.error(`[${this.name}] down (${logSafe(reason, 500)}), giving up until next use after ${this.restartsSinceUse - 1} failed restarts`);
650
697
  this.state = 'sleeping';
698
+ // lastGiveUpRetryAt stays as it is: zero after a first give-up, so the
699
+ // next request retries at once; the time of that retry when the retry
700
+ // is what failed, so the one after it waits for the backoff.
651
701
  this.rejectWakeWaiters(new Error(`Server "${this.name}" failed to start: ${reason}`));
652
702
  return;
653
703
  }
@@ -1,3 +1,5 @@
1
+ import { StringDecoder } from 'node:string_decoder';
2
+ import { logSafe } from '../auth/text.js';
1
3
  import { buildCreateRequest, containerName } from '../sandbox/container-spec.js';
2
4
  import { StreamTransport, setTransportHandlers } from './stream.js';
3
5
  /** Guards against a corrupt header turning into a multi-gigabyte allocation. */
@@ -15,7 +17,16 @@ const STDERR = 2;
15
17
  export class DockerFrameDecoder {
16
18
  onFrame;
17
19
  onError;
18
- buffer = Buffer.alloc(0);
20
+ /**
21
+ * The bytes of the frame in progress, as the chunks they arrived in, joined
22
+ * once the whole frame is here. One growing buffer would copy itself on every
23
+ * chunk — quadratic in the frame size, on the hub's event loop, for a size
24
+ * the peer chooses.
25
+ */
26
+ pending = [];
27
+ pendingBytes = 0;
28
+ /** The header of the frame in progress, once eight bytes have arrived. */
29
+ header;
19
30
  failed = false;
20
31
  constructor(onFrame, onError) {
21
32
  this.onFrame = onFrame;
@@ -24,25 +35,44 @@ export class DockerFrameDecoder {
24
35
  push(chunk) {
25
36
  if (this.failed)
26
37
  return;
27
- this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
38
+ if (chunk.length === 0)
39
+ return;
40
+ this.pending.push(chunk);
41
+ this.pendingBytes += chunk.length;
28
42
  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;
43
+ if (this.header === undefined) {
44
+ if (this.pendingBytes < 8)
45
+ return;
46
+ // Joining here is cheap: the pending pieces hold at most a header's
47
+ // worth of bytes plus whatever one chunk brought with it.
48
+ const joined = this.join();
49
+ const size = joined.readUInt32BE(4);
50
+ if (size > MAX_FRAME_BYTES) {
51
+ this.failed = true;
52
+ this.onError(new Error(`docker frame of ${size} bytes exceeds the ${MAX_FRAME_BYTES} byte limit`));
53
+ this.pending = [];
54
+ this.pendingBytes = 0;
55
+ return;
56
+ }
57
+ this.header = { stream: joined[0], size };
58
+ this.replace(joined.subarray(8));
37
59
  }
38
- if (this.buffer.length < 8 + size)
60
+ if (this.pendingBytes < this.header.size)
39
61
  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);
62
+ const joined = this.join();
63
+ const { stream, size } = this.header;
64
+ this.header = undefined;
65
+ this.replace(joined.subarray(size));
66
+ this.onFrame(stream, joined.subarray(0, size));
44
67
  }
45
68
  }
69
+ join() {
70
+ return this.pending.length === 1 ? this.pending[0] : Buffer.concat(this.pending);
71
+ }
72
+ replace(rest) {
73
+ this.pending = rest.length > 0 ? [rest] : [];
74
+ this.pendingBytes = rest.length;
75
+ }
46
76
  }
47
77
  /**
48
78
  * An MCP server running in its own container, spoken to over the Docker API.
@@ -68,6 +98,9 @@ export class DockerTransport {
68
98
  stream;
69
99
  closing = false;
70
100
  stderrTail = '';
101
+ // A frame boundary can split a multi-byte UTF-8 character; the decoder
102
+ // carries the partial bytes into the next frame.
103
+ stderrDecoder = new StringDecoder('utf8');
71
104
  constructor(server, config, client, writeStderr = line => process.stderr.write(line)) {
72
105
  this.server = server;
73
106
  this.config = config;
@@ -134,6 +167,12 @@ export class DockerTransport {
134
167
  this.closing = true;
135
168
  await this.inner?.close();
136
169
  this.stream?.destroy();
170
+ // A last line without a newline, or a character cut off by the exit, would
171
+ // otherwise go down with the container — and it is often the reason.
172
+ const rest = this.stderrTail + this.stderrDecoder.end();
173
+ this.stderrTail = '';
174
+ if (rest)
175
+ this.writeStderr(`[${this.server}] ${logSafe(rest, Infinity)}\n`);
137
176
  await this.client.removeContainer(containerName(this.server)).catch(() => { });
138
177
  }
139
178
  async ensureImage() {
@@ -149,15 +188,20 @@ export class DockerTransport {
149
188
  * to the process's stderr rather than through console: stdio children use
150
189
  * `stderr: 'inherit'` and bypass console too, which is what keeps LOG_FILE
151
190
  * (read by fail2ban) free of server chatter.
191
+ *
192
+ * The container is untrusted, so each line is escaped like any other text
193
+ * from a child: an ESC or a bare CR would otherwise reach the terminal of
194
+ * whoever watches `docker logs -f`. No length cap from logSafe — the 64 KiB
195
+ * tail flush below already bounds a line.
152
196
  */
153
197
  logStderr(payload) {
154
- this.stderrTail += payload.toString('utf8');
198
+ this.stderrTail += this.stderrDecoder.write(payload);
155
199
  const lines = this.stderrTail.split('\n');
156
200
  this.stderrTail = lines.pop() ?? '';
157
201
  for (const line of lines)
158
- this.writeStderr(`[${this.server}] ${line}\n`);
202
+ this.writeStderr(`[${this.server}] ${logSafe(line, Infinity)}\n`);
159
203
  if (this.stderrTail.length > 64 * 1024) {
160
- this.writeStderr(`[${this.server}] ${this.stderrTail}\n`);
204
+ this.writeStderr(`[${this.server}] ${logSafe(this.stderrTail, Infinity)}\n`);
161
205
  this.stderrTail = '';
162
206
  }
163
207
  }
@@ -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,7 +133,8 @@ 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
  }
@@ -4,7 +4,8 @@ import net from 'node:net';
4
4
  import { isPrivateAddress, resolvePublicAddress } from '../auth/address.js';
5
5
  import { boundedResponse, guardedRequest } from '../auth/pinned-fetch.js';
6
6
  import { logSafe } from '../auth/text.js';
7
- import { UpstreamAuthProvider, callbackUrl, credentialFingerprint, hubClientMetadata } from './provider.js';
7
+ import { UpstreamAuthProvider, callbackUrl, credentialFingerprint, hubClientMetadata, wellFormedOrUndefined } from './provider.js';
8
+ import { boundedRedirectFetch } from './redirects.js';
8
9
  /**
9
10
  * Everything the hub needs to authenticate itself to one upstream MCP server.
10
11
  *
@@ -69,10 +70,18 @@ export class UpstreamAuth {
69
70
  get record() {
70
71
  return this.store.getUpstreamCredentials(this.identity.serverName, this.fingerprint);
71
72
  }
72
- /** The full pair including the refresh token: this class is the only thing
73
- * allowed to spend it, which is why the provider withholds it. */
73
+ /**
74
+ * The full pair including the refresh token: this class is the only thing
75
+ * allowed to spend it, which is why the provider withholds it.
76
+ *
77
+ * A stored record may predate `saveTokens()`'s validation, or have been
78
+ * written by another process — `wellFormedOrUndefined` treats a malformed
79
+ * one as though nothing were stored, so it can never reach `send()`'s
80
+ * `headers.set()` below and throw with the token embedded in its own
81
+ * message.
82
+ */
74
83
  tokens() {
75
- return this.record?.tokens;
84
+ return wellFormedOrUndefined(this.record?.tokens);
76
85
  }
77
86
  /** The public key the upstream needs, but only when we sign assertions. */
78
87
  async publicJwkIfNeeded() {
@@ -228,7 +237,15 @@ export class UpstreamAuth {
228
237
  const resource = discovery.resourceMetadata?.resource ? new URL(discovery.resourceMetadata.resource) : undefined;
229
238
  if (this.identity.oauth.grant === 'client_credentials') {
230
239
  const tokens = await this.fetchClientCredentialsTokens(discovery, clientInformation, resource);
231
- this.provider().saveTokens(tokens);
240
+ try {
241
+ this.provider().saveTokens(tokens);
242
+ }
243
+ catch (error) {
244
+ // Same failure class as a refused refresh below: a human has to act,
245
+ // and restarting on a timer would only ask the same broken
246
+ // authorization server the same question forever.
247
+ throw new UpstreamLoginRequiredError(this.identity.serverName, error.message);
248
+ }
232
249
  return;
233
250
  }
234
251
  const refreshToken = this.tokens()?.refresh_token;
@@ -380,6 +397,9 @@ export class UpstreamAuth {
380
397
  */
381
398
  createFetch() {
382
399
  const upstreamOrigin = new URL(this.identity.serverUrl).origin;
400
+ // Data-plane redirects stay within the upstream's origin, three at most;
401
+ // the control plane refuses them outright in asFetch.
402
+ const dataPlane = boundedRedirectFetch(upstreamOrigin);
383
403
  return async (input, init) => {
384
404
  const url = new URL(input instanceof Request ? input.url : String(input));
385
405
  const isControlPlane = url.origin !== upstreamOrigin || url.pathname.startsWith('/.well-known/');
@@ -394,7 +414,7 @@ export class UpstreamAuth {
394
414
  if (!headers.has(key))
395
415
  headers.set(key, value);
396
416
  }
397
- return { response: await fetch(url, { ...init, headers }), token };
417
+ return { response: await dataPlane(url, { ...init, headers }), token };
398
418
  };
399
419
  const first = await send();
400
420
  if (first.response.status !== 401)
@@ -78,6 +78,57 @@ export function hubClientMetadata(identity, publicJwk) {
78
78
  }
79
79
  /** RFC 7523 §2.2: assertions are single-use and short-lived. */
80
80
  const ASSERTION_LIFETIME_S = 300;
81
+ /**
82
+ * The shape every token from an upstream has to fit before the hub stores it:
83
+ * printable ASCII without whitespace, at most 16 KiB.
84
+ *
85
+ * RFC 6749 leaves a token's format to the authorization server, but every real
86
+ * one issues an opaque printable string. The hub sends it back as a header
87
+ * value, and `Headers.set()` throws on CR or LF, quoting the value in its
88
+ * message. Stored anyway, such a token would break every later request to that
89
+ * server rather than this one, and put the value into whatever error reached a
90
+ * caller. Past 16 KiB a token is a payload, not an identifier.
91
+ */
92
+ const MAX_TOKEN_BYTES = 16 * 1024;
93
+ const TOKEN_PATTERN = /^[\x21-\x7e]+$/;
94
+ function isWellFormedToken(value) {
95
+ return typeof value === 'string' && value.length > 0 && value.length <= MAX_TOKEN_BYTES && TOKEN_PATTERN.test(value);
96
+ }
97
+ /**
98
+ * Refuses a token pair the upstream cannot have meant, before anything reaches
99
+ * the state file. Neither the log line nor the thrown message carries the
100
+ * value: a malformed token can still be somebody's secret.
101
+ */
102
+ function assertWellFormedTokens(tokens) {
103
+ const fields = [
104
+ ['access_token', tokens.access_token],
105
+ ['refresh_token', tokens.refresh_token]
106
+ ];
107
+ for (const [field, value] of fields) {
108
+ if (field === 'refresh_token' && value === undefined)
109
+ continue;
110
+ if (isWellFormedToken(value))
111
+ continue;
112
+ const length = typeof value === 'string' ? value.length : 0;
113
+ console.error(`mcp-hub: rejected a malformed ${field} from an upstream authorization server (${length} characters)`);
114
+ throw new Error(`upstream returned a malformed ${field}`);
115
+ }
116
+ }
117
+ /**
118
+ * A stored token pair, or undefined when it does not pass the same check. A
119
+ * record may predate the check or come from another process; treating it as
120
+ * absent means the next request refreshes or asks for a login instead of
121
+ * building a header that throws.
122
+ */
123
+ export function wellFormedOrUndefined(tokens) {
124
+ if (!tokens)
125
+ return undefined;
126
+ if (!isWellFormedToken(tokens.access_token))
127
+ return undefined;
128
+ if (tokens.refresh_token !== undefined && !isWellFormedToken(tokens.refresh_token))
129
+ return undefined;
130
+ return tokens;
131
+ }
81
132
  export class UpstreamAuthProvider {
82
133
  identity;
83
134
  store;
@@ -148,7 +199,9 @@ export class UpstreamAuthProvider {
148
199
  ...(typeof information.client_secret === 'string' ? { clientSecret: information.client_secret } : {}),
149
200
  // RFC 7592 credentials, when the upstream issued them — what `upstream
150
201
  // logout` needs to delete the registration again.
151
- ...(typeof information.registration_access_token === 'string'
202
+ // It goes out as a bearer header later, so it has to fit the same shape
203
+ // as any other token; a malformed one is dropped rather than stored.
204
+ ...(isWellFormedToken(information.registration_access_token)
152
205
  ? { registrationAccessToken: information.registration_access_token }
153
206
  : {}),
154
207
  ...(typeof information.registration_client_uri === 'string'
@@ -165,7 +218,7 @@ export class UpstreamAuthProvider {
165
218
  * the whole family. Refresh belongs to UpstreamAuth, which serializes it.
166
219
  */
167
220
  tokens() {
168
- const stored = this.record?.tokens;
221
+ const stored = wellFormedOrUndefined(this.record?.tokens);
169
222
  if (!stored)
170
223
  return undefined;
171
224
  const { refresh_token: _withheld, ...rest } = stored;
@@ -173,9 +226,10 @@ export class UpstreamAuthProvider {
173
226
  }
174
227
  /** The full pair, for the one caller that is allowed to spend it. */
175
228
  storedTokens() {
176
- return this.record?.tokens;
229
+ return wellFormedOrUndefined(this.record?.tokens);
177
230
  }
178
231
  saveTokens(tokens) {
232
+ assertWellFormedTokens(tokens);
179
233
  const expiresIn = typeof tokens.expires_in === 'number' ? tokens.expires_in : undefined;
180
234
  this.patch({
181
235
  tokens: tokens,