@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.
- package/CHANGELOG.md +171 -0
- package/README.md +25 -12
- package/dist/admin.js +4 -4
- package/dist/auth/address.js +1 -1
- package/dist/auth/oidc/interactions.js +40 -25
- package/dist/auth/oidc/provider.js +12 -0
- package/dist/auth/password.js +50 -0
- package/dist/auth/pinned-fetch.js +30 -0
- package/dist/auth/registration.js +5 -5
- package/dist/auth/session.js +32 -5
- package/dist/auth/signed-token.js +6 -2
- package/dist/auth/store.js +37 -29
- package/dist/child-text.js +39 -0
- package/dist/config.js +1 -5
- package/dist/docker-proxy/index.js +9 -2
- package/dist/docker-proxy/secrets-watcher.js +1 -1
- package/dist/docker-proxy/secrets.js +5 -1
- package/dist/docker-proxy/server.js +14 -0
- package/dist/elicitation.js +0 -0
- package/dist/forward.js +0 -0
- package/dist/hub.js +14 -7
- package/dist/index.js +19 -2
- package/dist/stdio.js +3 -1
- package/dist/subscriptions.js +11 -0
- package/dist/supervisor.js +48 -17
- package/dist/transports/docker.js +54 -24
- package/dist/transports/stream.js +45 -9
- package/dist/upstream/auth.js +20 -9
- package/dist/upstream/provider.js +1 -1
- package/dist/upstream/redirects.js +86 -0
- package/dist/upstream/routes.js +27 -22
- package/package.json +5 -3
package/dist/supervisor.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
+
if (chunk.length === 0)
|
|
37
|
+
return;
|
|
38
|
+
this.pending.push(chunk);
|
|
39
|
+
this.pendingBytes += chunk.length;
|
|
28
40
|
for (;;) {
|
|
29
|
-
if (this.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
this.
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
58
|
+
if (this.pendingBytes < this.header.size)
|
|
39
59
|
return;
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
this.
|
|
43
|
-
this.
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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,8 +133,19 @@ 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
|
}
|
|
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
|
package/dist/upstream/auth.js
CHANGED
|
@@ -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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
package/dist/upstream/routes.js
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 : '';
|