@ni-c/mcp-hub 0.11.2 → 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 +36 -0
- package/dist/supervisor.js +25 -2
- package/dist/transports/docker.js +43 -15
- package/dist/transports/stream.js +35 -9
- package/dist/upstream/auth.js +5 -1
- package/dist/upstream/redirects.js +86 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
<!-- #region changelog -->
|
|
9
9
|
|
|
10
|
+
## [0.11.3] - 2026-09-12
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- **A remote upstream could redirect the hub anywhere.** The MCP requests to
|
|
15
|
+
a remote server — `tools/call`, the event stream, `subscriptions/listen` —
|
|
16
|
+
went out with the platform's default of following every redirect, and
|
|
17
|
+
neither the SDK's transports nor the hub's own fetch wrappers said
|
|
18
|
+
otherwise. An upstream answering with a `Location` on an internal address
|
|
19
|
+
had the hub connect there, send the JSON-RPC body and every configured
|
|
20
|
+
header except `Authorization` and `Cookie`, and read the answer as MCP. The
|
|
21
|
+
guard the authorization server always had now covers the data plane too: a
|
|
22
|
+
redirect is followed only within the origin of the configured `url` (same
|
|
23
|
+
scheme, host and port — `/mcp` to `/mcp/` keeps working), at most three
|
|
24
|
+
times, with the platform's method and body rules; anything else fails the
|
|
25
|
+
request with a reason that names the refused origin and nothing more.
|
|
26
|
+
Reported by the 2026-09-12 internal review.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **A peer could make the hub copy its input quadratically.** The byte-stream
|
|
31
|
+
transports — `type: "unix"`, `"tcp"` and the docker attach stream — appended
|
|
32
|
+
every chunk to one growing buffer, so a line or a frame delivered in small
|
|
33
|
+
pieces cost the event loop a copy of everything so far, per piece: ten
|
|
34
|
+
megabytes in 4 KiB pieces took close to a second, a 16 MiB docker frame two
|
|
35
|
+
and a half, and a peer chooses its piece size. Both decoders now collect the
|
|
36
|
+
pieces and join them once, which is linear; the existing caps (10 MiB per
|
|
37
|
+
line, 16 MiB per frame) are unchanged, and a property test holds the framing
|
|
38
|
+
identical however the bytes are cut.
|
|
39
|
+
- **A remote server that failed to connect was logged as "connection closed".**
|
|
40
|
+
The SDK closes the transport before `connect()` rejects, so the generic
|
|
41
|
+
close reason won the race and the rejection — which names the cause and may
|
|
42
|
+
carry a verdict no restart can fix — was thrown away. The exit is now
|
|
43
|
+
reported once, from the rejection, so a refused redirect, a TLS failure or
|
|
44
|
+
an unauthorized upstream reads as what it is.
|
|
45
|
+
|
|
10
46
|
## [0.11.2] - 2026-09-07
|
|
11
47
|
|
|
12
48
|
### Security
|
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
|
}
|
|
@@ -190,6 +196,8 @@ export class ManagedServer {
|
|
|
190
196
|
* kill the new child.
|
|
191
197
|
*/
|
|
192
198
|
generation = 0;
|
|
199
|
+
/** True while client.connect() is in flight; see start(). */
|
|
200
|
+
connecting = false;
|
|
193
201
|
/** The one upstream listen stream carrying this route's whole demand (modern era). */
|
|
194
202
|
upstream;
|
|
195
203
|
upstreamFilter;
|
|
@@ -353,13 +361,24 @@ export class ManagedServer {
|
|
|
353
361
|
inputRequired: { autoFulfill: false }
|
|
354
362
|
});
|
|
355
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;
|
|
356
373
|
try {
|
|
357
374
|
await client.connect(transport);
|
|
358
375
|
}
|
|
359
376
|
catch (error) {
|
|
377
|
+
this.connecting = false;
|
|
360
378
|
this.onExit(`failed to start: ${error.message}`, generation, classifyAuthFailure(error));
|
|
361
379
|
return;
|
|
362
380
|
}
|
|
381
|
+
this.connecting = false;
|
|
363
382
|
if (generation !== this.generation) {
|
|
364
383
|
// sleep()/stop() ran while we were connecting; it already set the final
|
|
365
384
|
// state, so this child is surplus and only needs to go away again.
|
|
@@ -615,6 +634,10 @@ export class ManagedServer {
|
|
|
615
634
|
// left behind must not touch the current child.
|
|
616
635
|
if (generation !== this.generation)
|
|
617
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;
|
|
618
641
|
// A failed start reports twice: transport.onclose fires and start()'s catch
|
|
619
642
|
// calls us as well. Without this guard the second call would overwrite
|
|
620
643
|
// restartTimer without clearing it, so two children would be spawned and
|
|
@@ -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.
|
|
@@ -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
|
@@ -5,6 +5,7 @@ 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
7
|
import { UpstreamAuthProvider, callbackUrl, credentialFingerprint, hubClientMetadata } 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
|
*
|
|
@@ -380,6 +381,9 @@ export class UpstreamAuth {
|
|
|
380
381
|
*/
|
|
381
382
|
createFetch() {
|
|
382
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);
|
|
383
387
|
return async (input, init) => {
|
|
384
388
|
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
385
389
|
const isControlPlane = url.origin !== upstreamOrigin || url.pathname.startsWith('/.well-known/');
|
|
@@ -394,7 +398,7 @@ export class UpstreamAuth {
|
|
|
394
398
|
if (!headers.has(key))
|
|
395
399
|
headers.set(key, value);
|
|
396
400
|
}
|
|
397
|
-
return { response: await
|
|
401
|
+
return { response: await dataPlane(url, { ...init, headers }), token };
|
|
398
402
|
};
|
|
399
403
|
const first = await send();
|
|
400
404
|
if (first.response.status !== 401)
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ni-c/mcp-hub",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.3",
|
|
4
4
|
"description": "Serve multiple stdio MCP servers from one container: Claude-Code-style mcpServers config, path-based routing, hub meta-tools, and CIMD-first OAuth 2.1 + API tokens for ChatGPT, Claude and any Streamable-HTTP MCP client.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|