@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.
- package/CHANGELOG.md +110 -0
- package/README.md +6 -5
- package/dist/admin.js +8 -3
- package/dist/auth/cimd.js +8 -2
- package/dist/auth/oidc/interactions.js +14 -3
- package/dist/auth/oidc/mount.js +6 -1
- package/dist/auth/oidc/provider.js +23 -2
- package/dist/auth/oidc/verifier.js +35 -4
- package/dist/auth/page.js +9 -3
- package/dist/auth/redirect-uri.js +40 -0
- package/dist/auth/resource.js +16 -0
- package/dist/auth/store.js +35 -4
- package/dist/auth/text.js +9 -0
- package/dist/index.js +4 -1
- package/dist/subscriptions.js +23 -0
- package/dist/supervisor.js +58 -8
- package/dist/transports/docker.js +62 -18
- package/dist/transports/stream.js +35 -9
- package/dist/upstream/auth.js +26 -6
- package/dist/upstream/provider.js +57 -3
- package/dist/upstream/redirects.js +181 -0
- package/package.json +2 -2
package/dist/subscriptions.js
CHANGED
|
@@ -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);
|
package/dist/supervisor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 === '
|
|
265
|
-
//
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
+
if (chunk.length === 0)
|
|
39
|
+
return;
|
|
40
|
+
this.pending.push(chunk);
|
|
41
|
+
this.pendingBytes += chunk.length;
|
|
28
42
|
for (;;) {
|
|
29
|
-
if (this.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
this.
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
60
|
+
if (this.pendingBytes < this.header.size)
|
|
39
61
|
return;
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
this.
|
|
43
|
-
this.
|
|
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 +=
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
76
|
-
|
|
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.
|
|
136
|
+
this.pending = [];
|
|
137
|
+
this.pendingBytes = 0;
|
|
112
138
|
this.onclose?.();
|
|
113
139
|
}
|
|
114
140
|
}
|
package/dist/upstream/auth.js
CHANGED
|
@@ -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
|
-
/**
|
|
73
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|