@ni-c/mcp-hub 0.11.0 → 0.11.2
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 +173 -0
- package/README.md +26 -11
- 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 +23 -15
- package/dist/transports/docker.js +11 -9
- package/dist/transports/stream.js +10 -0
- package/dist/upstream/auth.js +15 -8
- package/dist/upstream/provider.js +1 -1
- package/dist/upstream/routes.js +27 -22
- package/package.json +6 -3
- package/dist/admin.js.map +0 -1
- package/dist/auth/address.js.map +0 -1
- package/dist/auth/api-tokens.js.map +0 -1
- package/dist/auth/cimd.js.map +0 -1
- package/dist/auth/consent-page.js.map +0 -1
- package/dist/auth/headers.js.map +0 -1
- package/dist/auth/login-page.js.map +0 -1
- package/dist/auth/oidc/adapter.js.map +0 -1
- package/dist/auth/oidc/interactions.js.map +0 -1
- package/dist/auth/oidc/mount.js.map +0 -1
- package/dist/auth/oidc/provider.js.map +0 -1
- package/dist/auth/oidc/quirks.js.map +0 -1
- package/dist/auth/oidc/verifier.js.map +0 -1
- package/dist/auth/page.js.map +0 -1
- package/dist/auth/pinned-fetch.js.map +0 -1
- package/dist/auth/protected-resource.js.map +0 -1
- package/dist/auth/rate-limit.js.map +0 -1
- package/dist/auth/redirect-uri.js.map +0 -1
- package/dist/auth/registration.js.map +0 -1
- package/dist/auth/resource.js.map +0 -1
- package/dist/auth/session.js.map +0 -1
- package/dist/auth/signed-token.js.map +0 -1
- package/dist/auth/store.js.map +0 -1
- package/dist/auth/text.js.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/docker-proxy/index.js.map +0 -1
- package/dist/docker-proxy/policy.js.map +0 -1
- package/dist/docker-proxy/secrets-watcher.js.map +0 -1
- package/dist/docker-proxy/secrets.js.map +0 -1
- package/dist/docker-proxy/server.js.map +0 -1
- package/dist/elicitation.js.map +0 -1
- package/dist/forward.js.map +0 -1
- package/dist/health.js.map +0 -1
- package/dist/hub.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/limits.js.map +0 -1
- package/dist/logfile.js.map +0 -1
- package/dist/main-module.js.map +0 -1
- package/dist/mcp-limits.js.map +0 -1
- package/dist/mount-check.js.map +0 -1
- package/dist/proxy.js.map +0 -1
- package/dist/sandbox/container-spec.js.map +0 -1
- package/dist/sandbox/docker-client.js.map +0 -1
- package/dist/sandbox/policy-protocol.js.map +0 -1
- package/dist/stdio.js.map +0 -1
- package/dist/subscriptions.js.map +0 -1
- package/dist/supervisor.js.map +0 -1
- package/dist/timings.js.map +0 -1
- package/dist/tool-cache.js.map +0 -1
- package/dist/tool-filter.js.map +0 -1
- package/dist/transports/docker.js.map +0 -1
- package/dist/transports/socket.js.map +0 -1
- package/dist/transports/stream.js.map +0 -1
- package/dist/upstream/auth.js.map +0 -1
- package/dist/upstream/login.js.map +0 -1
- package/dist/upstream/provider.js.map +0 -1
- package/dist/upstream/routes.js.map +0 -1
- package/dist/version.js.map +0 -1
package/dist/auth/session.js
CHANGED
|
@@ -2,6 +2,20 @@ import { sign, signatureMatches } from './signed-token.js';
|
|
|
2
2
|
/** Deliberately short: it only has to outlive a connector's authorization. */
|
|
3
3
|
export const SESSION_TTL_MS = 30 * 60_000;
|
|
4
4
|
export const SESSION_COOKIE = 'mcp_hub_session';
|
|
5
|
+
/**
|
|
6
|
+
* The cookie's name, which behind HTTPS carries the `__Host-` prefix.
|
|
7
|
+
*
|
|
8
|
+
* The prefix is a promise the browser enforces: such a cookie is only accepted
|
|
9
|
+
* from a secure origin, with `Path=/` and without a `Domain`, and can therefore
|
|
10
|
+
* not be planted by a sibling subdomain or over plain http on the same host.
|
|
11
|
+
* The value is signed either way, so a planted cookie could not be a forged
|
|
12
|
+
* session — but it could be a *real* one an attacker obtained, fixed into
|
|
13
|
+
* somebody else's browser. Behind plain http (a development hub, the test
|
|
14
|
+
* suite) the prefix is not settable at all, so the bare name is used there.
|
|
15
|
+
*/
|
|
16
|
+
export function sessionCookieName(secure) {
|
|
17
|
+
return secure ? `__Host-${SESSION_COOKIE}` : SESSION_COOKIE;
|
|
18
|
+
}
|
|
5
19
|
/**
|
|
6
20
|
* The operator's browser session, carried entirely by the client.
|
|
7
21
|
*
|
|
@@ -19,13 +33,26 @@ export function createSessionCookie(secret) {
|
|
|
19
33
|
const expires = String(Date.now() + SESSION_TTL_MS);
|
|
20
34
|
return `${expires}.${sign(expires, secret)}`;
|
|
21
35
|
}
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
36
|
+
/**
|
|
37
|
+
* The verified cookie value, or undefined when absent, forged or expired.
|
|
38
|
+
*
|
|
39
|
+
* Only the name for this deployment is read: behind HTTPS the bare name is not
|
|
40
|
+
* the session cookie, whatever it carries.
|
|
41
|
+
*/
|
|
42
|
+
export function readSessionCookie(cookieHeader, secret, secure = false) {
|
|
43
|
+
const match = cookieHeader?.match(new RegExp(`(?:^|;\\s*)${sessionCookieName(secure)}=([^;]+)`));
|
|
25
44
|
if (!match)
|
|
26
45
|
return undefined;
|
|
27
|
-
|
|
28
|
-
|
|
46
|
+
let value;
|
|
47
|
+
try {
|
|
48
|
+
value = decodeURIComponent(match[1]);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const [expires, signature, extra] = value.split('.');
|
|
54
|
+
if (extra !== undefined)
|
|
55
|
+
return undefined;
|
|
29
56
|
if (!expires || !signature)
|
|
30
57
|
return undefined;
|
|
31
58
|
if (!signatureMatches(expires, signature, secret))
|
|
@@ -21,7 +21,9 @@ export function sign(value, secret) {
|
|
|
21
21
|
*/
|
|
22
22
|
export function signatureMatches(value, signature, secret) {
|
|
23
23
|
const expected = sign(value, secret);
|
|
24
|
-
|
|
24
|
+
// Character lengths alone do not bound the UTF-8 buffers timingSafeEqual
|
|
25
|
+
// compares. A multibyte forgery must be refused, never throw a RangeError.
|
|
26
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(signature))
|
|
25
27
|
return false;
|
|
26
28
|
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
|
27
29
|
}
|
|
@@ -34,7 +36,9 @@ export function signPayload(payload, secret) {
|
|
|
34
36
|
/** Undefined for anything that was not signed with this secret, or is not the
|
|
35
37
|
* shape it claims. Never throws on malformed input. */
|
|
36
38
|
export function readSignedPayload(token, secret) {
|
|
37
|
-
const [encoded, signature] = token.split('.');
|
|
39
|
+
const [encoded, signature, extra] = token.split('.');
|
|
40
|
+
if (extra !== undefined)
|
|
41
|
+
return undefined;
|
|
38
42
|
if (!encoded || !signature)
|
|
39
43
|
return undefined;
|
|
40
44
|
if (!signatureMatches(encoded, signature, secret))
|
package/dist/auth/store.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { logSafe } from './text.js';
|
|
4
5
|
export const DEFAULT_CLIENT_LIMITS = {
|
|
5
6
|
maxClients: 500,
|
|
6
7
|
pendingTtlSeconds: 24 * 3600,
|
|
@@ -60,6 +61,23 @@ const LOCK_WAIT_MS = 10_000;
|
|
|
60
61
|
const STALE_LOCK_MS = 30_000;
|
|
61
62
|
const LOCK_POLL_MS = 10;
|
|
62
63
|
const lockSleep = new Int32Array(new SharedArrayBuffer(4));
|
|
64
|
+
/**
|
|
65
|
+
* A map keyed by something a caller chose, with nothing inherited to find.
|
|
66
|
+
*
|
|
67
|
+
* Every map in the state file is indexed by an identifier from outside — a
|
|
68
|
+
* `client_id` from a registration or a URL, a token id from a request, a server
|
|
69
|
+
* name — and `JSON.parse` hands them back as ordinary objects whose prototype
|
|
70
|
+
* answers to `constructor`, `hasOwnProperty` and `__proto__`. Looked up that
|
|
71
|
+
* way, `state.clients['constructor']` is `Object` itself: a truthy record that
|
|
72
|
+
* is not a client, handed to the authorization server as if it were one, which
|
|
73
|
+
* answered `500 server_error` to `/authorize?client_id=constructor` and logged
|
|
74
|
+
* a server fault for every such request. A null prototype leaves nothing to
|
|
75
|
+
* find, so an unknown name is `undefined` on every path without each of the
|
|
76
|
+
* forty lookups having to remember `Object.hasOwn`.
|
|
77
|
+
*/
|
|
78
|
+
function bare(record) {
|
|
79
|
+
return Object.assign(Object.create(null), record ?? {});
|
|
80
|
+
}
|
|
63
81
|
/**
|
|
64
82
|
* All persistent auth state lives in two files under DATA_PATH:
|
|
65
83
|
* jwt-key.pem (Ed25519 private key) and state.json (clients, refresh tokens,
|
|
@@ -103,19 +121,7 @@ export class AuthStore {
|
|
|
103
121
|
this.privateKey = crypto.createPrivateKey(fs.readFileSync(keyPath, 'utf8'));
|
|
104
122
|
this.publicKey = crypto.createPublicKey(this.privateKey);
|
|
105
123
|
const restored = AuthStore.readState(this.statePath);
|
|
106
|
-
this.state = restored ?? {
|
|
107
|
-
cookieSecret: crypto.randomBytes(32).toString('base64url'),
|
|
108
|
-
clients: {},
|
|
109
|
-
refreshTokens: {},
|
|
110
|
-
approvals: {},
|
|
111
|
-
consumedRefreshTokens: {},
|
|
112
|
-
revokedBefore: {},
|
|
113
|
-
apiTokens: {},
|
|
114
|
-
clientLifecycle: {},
|
|
115
|
-
upstreamCredentials: {},
|
|
116
|
-
upstreamLogins: {},
|
|
117
|
-
oidcArtifacts: {}
|
|
118
|
-
};
|
|
124
|
+
this.state = restored ?? AuthStore.normalize({ cookieSecret: crypto.randomBytes(32).toString('base64url') });
|
|
119
125
|
if (restored)
|
|
120
126
|
this.signature = this.fileSignature();
|
|
121
127
|
else
|
|
@@ -280,7 +286,9 @@ export class AuthStore {
|
|
|
280
286
|
this.persistUnlocked();
|
|
281
287
|
return;
|
|
282
288
|
}
|
|
283
|
-
throw new Error(`cannot reload auth state while holding the mutation lock: ${error.message}
|
|
289
|
+
throw new Error(`cannot reload auth state while holding the mutation lock: ${error.message}`, {
|
|
290
|
+
cause: error
|
|
291
|
+
});
|
|
284
292
|
}
|
|
285
293
|
const next = AuthStore.normalize(parsed);
|
|
286
294
|
if (!next)
|
|
@@ -300,16 +308,16 @@ export class AuthStore {
|
|
|
300
308
|
return undefined;
|
|
301
309
|
return {
|
|
302
310
|
cookieSecret: state.cookieSecret,
|
|
303
|
-
clients: state.clients
|
|
304
|
-
refreshTokens: state.refreshTokens
|
|
305
|
-
approvals: state.approvals
|
|
306
|
-
consumedRefreshTokens: state.consumedRefreshTokens
|
|
307
|
-
revokedBefore: state.revokedBefore
|
|
308
|
-
apiTokens: state.apiTokens
|
|
309
|
-
clientLifecycle: state.clientLifecycle
|
|
310
|
-
upstreamCredentials: state.upstreamCredentials
|
|
311
|
-
upstreamLogins: state.upstreamLogins
|
|
312
|
-
oidcArtifacts: state.oidcArtifacts
|
|
311
|
+
clients: bare(state.clients),
|
|
312
|
+
refreshTokens: bare(state.refreshTokens),
|
|
313
|
+
approvals: bare(state.approvals),
|
|
314
|
+
consumedRefreshTokens: bare(state.consumedRefreshTokens),
|
|
315
|
+
revokedBefore: bare(state.revokedBefore),
|
|
316
|
+
apiTokens: bare(state.apiTokens),
|
|
317
|
+
clientLifecycle: bare(state.clientLifecycle),
|
|
318
|
+
upstreamCredentials: bare(state.upstreamCredentials),
|
|
319
|
+
upstreamLogins: bare(state.upstreamLogins),
|
|
320
|
+
oidcArtifacts: bare(Object.fromEntries(Object.entries(bare(state.oidcArtifacts)).map(([model, records]) => [model, bare(records)]))),
|
|
313
321
|
...(typeof state.externalUrl === 'string' ? { externalUrl: state.externalUrl } : {})
|
|
314
322
|
};
|
|
315
323
|
}
|
|
@@ -394,7 +402,7 @@ export class AuthStore {
|
|
|
394
402
|
}
|
|
395
403
|
const ids = Object.keys(records);
|
|
396
404
|
if (ids.length > MAX_OIDC_ARTIFACTS_PER_MODEL) {
|
|
397
|
-
const byAge = ids.
|
|
405
|
+
const byAge = ids.toSorted((a, b) => (records[a].expiresAt || Infinity) - (records[b].expiresAt || Infinity));
|
|
398
406
|
for (const id of byAge.slice(0, ids.length - MAX_OIDC_ARTIFACTS_PER_MODEL))
|
|
399
407
|
delete records[id];
|
|
400
408
|
}
|
|
@@ -556,7 +564,7 @@ export class AuthStore {
|
|
|
556
564
|
return true;
|
|
557
565
|
const evictable = Object.values(this.state.clients)
|
|
558
566
|
.filter(candidate => !this.state.approvals[candidate.client_id] && !this.isOperatorManagedUnderLock(candidate.client_id))
|
|
559
|
-
.
|
|
567
|
+
.toSorted((a, b) => (a.client_id_issued_at ?? 0) - (b.client_id_issued_at ?? 0));
|
|
560
568
|
for (const candidate of evictable) {
|
|
561
569
|
this.forgetClient(candidate.client_id);
|
|
562
570
|
if (fits())
|
|
@@ -621,7 +629,7 @@ export class AuthStore {
|
|
|
621
629
|
}
|
|
622
630
|
catch (error) {
|
|
623
631
|
// Bookkeeping must never be the reason an authorization fails.
|
|
624
|
-
console.warn(`mcp-hub: could not record activity for client ${clientId}: ${error.message}`);
|
|
632
|
+
console.warn(`mcp-hub: could not record activity for client ${logSafe(clientId)}: ${error.message}`);
|
|
625
633
|
}
|
|
626
634
|
}
|
|
627
635
|
/** Timing-safe check of an RFC 7592 registration access token. */
|
|
@@ -723,7 +731,7 @@ export class AuthStore {
|
|
|
723
731
|
pruneUnapprovedClients() {
|
|
724
732
|
const unapproved = Object.values(this.state.clients)
|
|
725
733
|
.filter(c => !this.state.approvals[c.client_id] && !this.isOperatorManagedUnderLock(c.client_id))
|
|
726
|
-
.
|
|
734
|
+
.toSorted((a, b) => (a.client_id_issued_at ?? 0) - (b.client_id_issued_at ?? 0));
|
|
727
735
|
for (let i = 0; i < unapproved.length - MAX_UNAPPROVED_CLIENTS; i++) {
|
|
728
736
|
this.forgetClient(unapproved[i].client_id);
|
|
729
737
|
}
|
|
@@ -892,7 +900,7 @@ export class AuthStore {
|
|
|
892
900
|
]);
|
|
893
901
|
oidcUpsert(model, id, payload, expiresInSeconds) {
|
|
894
902
|
this.mutate(() => {
|
|
895
|
-
const records = (this.state.oidcArtifacts[model] ??=
|
|
903
|
+
const records = (this.state.oidcArtifacts[model] ??= bare());
|
|
896
904
|
const key = AuthStore.artifactKey(id);
|
|
897
905
|
const stored = AuthStore.CREDENTIAL_MODELS.has(model) ? { ...payload, jti: undefined } : payload;
|
|
898
906
|
if (AuthStore.CREDENTIAL_MODELS.has(model))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { sanitiseText } from './elicitation.js';
|
|
2
|
+
/**
|
|
3
|
+
* A string a child server wrote, on its way into a tool result the model reads.
|
|
4
|
+
*
|
|
5
|
+
* The meta-tools answer with the hub's own words — `list_servers`, `list_tools`
|
|
6
|
+
* and `get_tool_schema` are built from the hub's snapshot — and until now they
|
|
7
|
+
* carried a child's `title`, `description` and error message into that answer
|
|
8
|
+
* untouched. Those are the one channel a child has into a conversation it is
|
|
9
|
+
* not part of: a bidi override reverses the line, a zero-width character hides
|
|
10
|
+
* what a model still reads, an ESC sequence lands in whatever terminal shows
|
|
11
|
+
* the transcript, and a description of any length crowds out the tools next
|
|
12
|
+
* to it. Stripped of the characters that let text lie about its shape (the
|
|
13
|
+
* same set an elicitation prompt loses), and cut to a size that fits the field.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately not applied to `inputSchema`, `outputSchema` or `annotations`:
|
|
16
|
+
* those are documents the client validates against and the hub passes on
|
|
17
|
+
* verbatim by contract — hub-tools.md says whose word they are.
|
|
18
|
+
*/
|
|
19
|
+
export function childText(value, max) {
|
|
20
|
+
if (typeof value !== 'string')
|
|
21
|
+
return '';
|
|
22
|
+
const clean = sanitiseText(value);
|
|
23
|
+
if (clean.length <= max)
|
|
24
|
+
return clean;
|
|
25
|
+
let cut = clean.slice(0, Math.max(0, max - 1));
|
|
26
|
+
// A cut can split a surrogate pair; a lone half serialises as an escape and
|
|
27
|
+
// fails a Python client's UTF-8 encoder.
|
|
28
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
29
|
+
if (last >= 0xd800 && last <= 0xdbff)
|
|
30
|
+
cut = cut.slice(0, -1);
|
|
31
|
+
return `${cut}…`;
|
|
32
|
+
}
|
|
33
|
+
/** What a child's error message may say inside the hub's own error sentence. */
|
|
34
|
+
export const MAX_CHILD_ERROR_CHARS = 500;
|
|
35
|
+
/** A title or a server's display name. */
|
|
36
|
+
export const MAX_CHILD_TITLE_CHARS = 200;
|
|
37
|
+
/** A full tool description, as `get_tool_schema` hands it on. */
|
|
38
|
+
export const MAX_CHILD_DESCRIPTION_CHARS = 16 * 1024;
|
|
39
|
+
//# sourceMappingURL=child-text.js.map
|
package/dist/config.js
CHANGED
|
@@ -412,12 +412,8 @@ function parseServer(name, entry, env, options) {
|
|
|
412
412
|
}
|
|
413
413
|
const headers = requireStringRecord(name, 'headers', entry.headers ?? {});
|
|
414
414
|
const url = expand(entry.url);
|
|
415
|
-
|
|
416
|
-
new URL(url);
|
|
417
|
-
}
|
|
418
|
-
catch {
|
|
415
|
+
if (!URL.canParse(url))
|
|
419
416
|
throw new ConfigError(`Server "${name}": "url" is not a valid URL`);
|
|
420
|
-
}
|
|
421
417
|
const oauth = parseUpstreamOAuth(name, entry.oauth, expand);
|
|
422
418
|
if (oauth && Object.keys(headers).some(key => key.toLowerCase() === 'authorization')) {
|
|
423
419
|
// The transport merges requestInit headers last, so the static one would
|
|
@@ -5,7 +5,7 @@ import { ConfigWatcher, loadConfig, warnMutableDockerImages } from '../config.js
|
|
|
5
5
|
import { installFileLogging } from '../logfile.js';
|
|
6
6
|
import { VERSION } from '../version.js';
|
|
7
7
|
import { containerName } from '../sandbox/container-spec.js';
|
|
8
|
-
import { createDockerProxy, recreateSandbox } from './server.js';
|
|
8
|
+
import { createDockerProxy, parseSocketMode, recreateSandbox } from './server.js';
|
|
9
9
|
import { SecretsWatcher } from './secrets-watcher.js';
|
|
10
10
|
import { SecretStore, validateConfigSecrets } from './secrets.js';
|
|
11
11
|
import { warnSingleFileMount } from '../mount-check.js';
|
|
@@ -28,7 +28,14 @@ const configPath = env('CONFIG_PATH', '/config/mcp.json');
|
|
|
28
28
|
const listenSocket = env('LISTEN_SOCKET', '/run/proxy/docker.sock');
|
|
29
29
|
const dockerSocket = env('DOCKER_SOCKET', '/var/run/docker.sock');
|
|
30
30
|
const secretsDir = env('SANDBOX_SECRETS_DIR', '/run/secrets');
|
|
31
|
-
|
|
31
|
+
let socketMode;
|
|
32
|
+
try {
|
|
33
|
+
socketMode = parseSocketMode(env('SOCKET_MODE', '0660'));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
console.error(`mcp-hub-docker-proxy: ${error.message}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
32
39
|
if (process.env.LOG_FILE) {
|
|
33
40
|
installFileLogging(process.env.LOG_FILE);
|
|
34
41
|
}
|
|
@@ -21,7 +21,7 @@ import { SecretError } from './secrets.js';
|
|
|
21
21
|
const DEBOUNCE_MS = 1_000;
|
|
22
22
|
/** Content identity of a secret set: key order and formatting do not matter. */
|
|
23
23
|
function fingerprint(secrets) {
|
|
24
|
-
return JSON.stringify(Object.entries(secrets).
|
|
24
|
+
return JSON.stringify(Object.entries(secrets).toSorted(([a], [b]) => a.localeCompare(b)));
|
|
25
25
|
}
|
|
26
26
|
/** All docker servers that reference a given secret set. */
|
|
27
27
|
function referencingServers(config, set) {
|
|
@@ -18,7 +18,11 @@ export class SecretError extends Error {
|
|
|
18
18
|
export function parseEnvFile(content) {
|
|
19
19
|
if (content.includes('\0'))
|
|
20
20
|
throw new SecretError('contains a NUL byte');
|
|
21
|
-
|
|
21
|
+
// Null prototype: `KEY_PATTERN` admits `__proto__`, and on an ordinary object
|
|
22
|
+
// `result['__proto__'] = value` replaces the prototype instead of adding a
|
|
23
|
+
// key — the variable vanished without a word, and the duplicate check two
|
|
24
|
+
// lines up could not see it either. With nothing inherited it is a key.
|
|
25
|
+
const result = Object.create(null);
|
|
22
26
|
let entries = 0;
|
|
23
27
|
for (const [index, raw] of content.split('\n').entries()) {
|
|
24
28
|
const line = raw.trim();
|
|
@@ -7,6 +7,20 @@ const MAX_BODY_BYTES = 1024 * 1024;
|
|
|
7
7
|
const UPSTREAM_TIMEOUT_MS = 15 * 60_000;
|
|
8
8
|
const POLICY_CHECK_TIMEOUT_MS = 5_000;
|
|
9
9
|
const MAX_INSPECT_BYTES = 1024 * 1024;
|
|
10
|
+
/**
|
|
11
|
+
* The mode of the listening socket, from SOCKET_MODE.
|
|
12
|
+
*
|
|
13
|
+
* `parseInt(value, 8)` read `abc` as NaN and `0777x` as 0777, and the NaN
|
|
14
|
+
* reached chmodSync inside the listen callback — an uncaught exception at
|
|
15
|
+
* startup with a stack trace where a sentence should be. Three or four octal
|
|
16
|
+
* digits, nothing else; the value is never echoed, it is one line below the
|
|
17
|
+
* secrets directory in every compose file.
|
|
18
|
+
*/
|
|
19
|
+
export function parseSocketMode(value) {
|
|
20
|
+
if (!/^[0-7]{3,4}$/.test(value))
|
|
21
|
+
throw new Error(`SOCKET_MODE must be three or four octal digits such as 0660 (${value.length} characters given)`);
|
|
22
|
+
return Number.parseInt(value, 8);
|
|
23
|
+
}
|
|
10
24
|
function refuse(response, status, reason, closeConnection = false) {
|
|
11
25
|
const body = JSON.stringify({ message: `mcp-hub-docker-proxy: ${reason}` });
|
|
12
26
|
response.writeHead(status, {
|
package/dist/elicitation.js
CHANGED
|
Binary file
|
package/dist/forward.js
CHANGED
|
Binary file
|
package/dist/hub.js
CHANGED
|
@@ -6,6 +6,7 @@ import { loggableToolName, toolAllowed } from './tool-filter.js';
|
|
|
6
6
|
import { booleanEnv } from './mcp-limits.js';
|
|
7
7
|
import { REFUSAL_REASON, decidePassthrough } from './elicitation.js';
|
|
8
8
|
import { REVISION } from './proxy.js';
|
|
9
|
+
import { MAX_CHILD_DESCRIPTION_CHARS, MAX_CHILD_ERROR_CHARS, MAX_CHILD_TITLE_CHARS, childText } from './child-text.js';
|
|
9
10
|
/**
|
|
10
11
|
* A meta-tool answer, in both channels at once.
|
|
11
12
|
*
|
|
@@ -119,9 +120,13 @@ function toolError(message) {
|
|
|
119
120
|
function firstLine(description) {
|
|
120
121
|
if (!description)
|
|
121
122
|
return '';
|
|
122
|
-
const line = description.split('\n', 1)[0].trim();
|
|
123
|
+
const line = childText(description, MAX_CHILD_DESCRIPTION_CHARS).split('\n', 1)[0].trim();
|
|
123
124
|
return line.length > 120 ? `${line.slice(0, 117)}...` : line;
|
|
124
125
|
}
|
|
126
|
+
/** A child's failure, as the hub's own error sentence may quote it. */
|
|
127
|
+
function reason(error) {
|
|
128
|
+
return childText(error instanceof Error ? error.message : String(error), MAX_CHILD_ERROR_CHARS);
|
|
129
|
+
}
|
|
125
130
|
/**
|
|
126
131
|
* The four hints the three reading meta-tools carry.
|
|
127
132
|
*
|
|
@@ -212,7 +217,7 @@ export function buildHubServer(supervisor, secret, era) {
|
|
|
212
217
|
await managed.wake();
|
|
213
218
|
}
|
|
214
219
|
catch (error) {
|
|
215
|
-
return toolError(`Server "${managed.name}" failed to start: ${error
|
|
220
|
+
return toolError(`Server "${managed.name}" failed to start: ${reason(error)}`);
|
|
216
221
|
}
|
|
217
222
|
}
|
|
218
223
|
else if (managed.state === 'sleeping') {
|
|
@@ -230,7 +235,9 @@ export function buildHubServer(supervisor, secret, era) {
|
|
|
230
235
|
}, async () => structured({
|
|
231
236
|
servers: [...supervisor.servers.values()].map(s => ({
|
|
232
237
|
name: s.name,
|
|
233
|
-
|
|
238
|
+
// The child's own words, and the one field of this answer that is:
|
|
239
|
+
// cleaned and cut like a description, never carried verbatim.
|
|
240
|
+
description: childText(s.serverInfo?.title ?? s.serverInfo?.name, MAX_CHILD_TITLE_CHARS),
|
|
234
241
|
status: s.state,
|
|
235
242
|
toolCount: s.tools.length,
|
|
236
243
|
...(s.config.hub ? {} : { hidden: true })
|
|
@@ -302,7 +309,7 @@ export function buildHubServer(supervisor, secret, era) {
|
|
|
302
309
|
return structured({
|
|
303
310
|
server: managed.name,
|
|
304
311
|
name: found.name,
|
|
305
|
-
description: found.description
|
|
312
|
+
description: childText(found.description, MAX_CHILD_DESCRIPTION_CHARS),
|
|
306
313
|
inputSchema: found.inputSchema,
|
|
307
314
|
// The reason this tool exists twice over: call_tool hands back the
|
|
308
315
|
// child's structuredContent, and until this line the caller had no way
|
|
@@ -362,7 +369,7 @@ export function buildHubServer(supervisor, secret, era) {
|
|
|
362
369
|
await managed.wake();
|
|
363
370
|
}
|
|
364
371
|
catch (error) {
|
|
365
|
-
return toolError(`Server "${server}" failed to start: ${error
|
|
372
|
+
return toolError(`Server "${server}" failed to start: ${reason(error)}`);
|
|
366
373
|
}
|
|
367
374
|
}
|
|
368
375
|
if (managed.state !== 'up' || !managed.client)
|
|
@@ -386,7 +393,7 @@ export function buildHubServer(supervisor, secret, era) {
|
|
|
386
393
|
// Aggregate semantics: every failure here is a tool result, never a
|
|
387
394
|
// protocol error, because six meta-tools stand in for every server and
|
|
388
395
|
// one unreachable child must not look like a broken hub.
|
|
389
|
-
return toolError(`Tool call failed: ${error
|
|
396
|
+
return toolError(`Tool call failed: ${reason(error)}`);
|
|
390
397
|
}
|
|
391
398
|
});
|
|
392
399
|
hub.registerTool('wake_server', {
|
|
@@ -405,7 +412,7 @@ export function buildHubServer(supervisor, secret, era) {
|
|
|
405
412
|
await managed.wake();
|
|
406
413
|
}
|
|
407
414
|
catch (error) {
|
|
408
|
-
return toolError(`Server "${server}" failed to start: ${error
|
|
415
|
+
return toolError(`Server "${server}" failed to start: ${reason(error)}`);
|
|
409
416
|
}
|
|
410
417
|
managed.markUsed();
|
|
411
418
|
return structured({ name: managed.name, status: managed.state, toolCount: managed.tools.length });
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { mountOidcProvider } from './auth/oidc/mount.js';
|
|
|
15
15
|
import { buildOidcProvider } from './auth/oidc/provider.js';
|
|
16
16
|
import { OidcTokenVerifier } from './auth/oidc/verifier.js';
|
|
17
17
|
import { authSecurityHeaders } from './auth/headers.js';
|
|
18
|
+
import { operatorCredential } from './auth/password.js';
|
|
18
19
|
import { createProtectedResourceRoutes } from './auth/protected-resource.js';
|
|
19
20
|
import { createRegistrationManagementRoutes } from './auth/registration.js';
|
|
20
21
|
import { createUpstreamRoutes } from './upstream/routes.js';
|
|
@@ -30,6 +31,13 @@ import { IDLE_TIMEOUT_MS } from './timings.js';
|
|
|
30
31
|
const CLIENT_PRUNE_INTERVAL_MS = 15 * 60_000;
|
|
31
32
|
export const CLIENT_REGISTRATION_MECHANISMS = ['cimd', 'dcr'];
|
|
32
33
|
export async function createHub(options) {
|
|
34
|
+
// Said before anything else is built, so it is the first line an operator
|
|
35
|
+
// reads. The hub still starts: with no usable password nobody can approve a
|
|
36
|
+
// client, so nothing is reachable, and a process that runs and says why is
|
|
37
|
+
// more useful to a health check or a directory crawler than one that exits.
|
|
38
|
+
const credential = operatorCredential(options);
|
|
39
|
+
if (!credential.enabled)
|
|
40
|
+
console.warn(`mcp-hub: ${credential.problem}`);
|
|
33
41
|
// Canonical issuer identifier: URL.href form ('https://host/' for a root
|
|
34
42
|
// URL), so JWT iss/aud, AS metadata issuer and PRM authorization_servers all
|
|
35
43
|
// match byte-for-byte — claude.ai compares these strictly.
|
|
@@ -346,16 +354,25 @@ function cimdAllowedOriginsEnv() {
|
|
|
346
354
|
url = new URL(entry);
|
|
347
355
|
}
|
|
348
356
|
catch {
|
|
349
|
-
console.error(`mcp-hub: CIMD_ALLOWED_ORIGINS entry
|
|
357
|
+
console.error(`mcp-hub: CIMD_ALLOWED_ORIGINS entry ${describeEntry(entry)} is not a URL`);
|
|
350
358
|
process.exit(1);
|
|
351
359
|
}
|
|
352
360
|
if (url.protocol !== 'https:' || url.origin !== entry.replace(/\/$/, '')) {
|
|
353
|
-
console.error(`mcp-hub: CIMD_ALLOWED_ORIGINS entry
|
|
361
|
+
console.error(`mcp-hub: CIMD_ALLOWED_ORIGINS entry ${describeEntry(entry)} must be a bare https origin, e.g. https://chatgpt.com`);
|
|
354
362
|
process.exit(1);
|
|
355
363
|
}
|
|
356
364
|
}
|
|
357
365
|
return entries.map(entry => new URL(entry).origin);
|
|
358
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* A configuration value in a diagnostic, quoted only when it has the shape of
|
|
369
|
+
* one. CIMD_ALLOWED_ORIGINS sits a few lines from PASSWORD_HASH in every
|
|
370
|
+
* compose file, and a value that is not an origin is exactly what a secret
|
|
371
|
+
* pasted into the wrong line looks like.
|
|
372
|
+
*/
|
|
373
|
+
function describeEntry(entry) {
|
|
374
|
+
return /^https?:\/\/[^\s]{1,120}$/i.test(entry) ? `"${entry}"` : `(a ${entry.length}-character value that does not look like an origin)`;
|
|
375
|
+
}
|
|
359
376
|
function nonNegativeIntegerEnv(name, fallback) {
|
|
360
377
|
const raw = process.env[name];
|
|
361
378
|
if (raw === undefined)
|
package/dist/stdio.js
CHANGED
|
@@ -22,7 +22,6 @@ import { IDLE_TIMEOUT_MS } from './timings.js';
|
|
|
22
22
|
*/
|
|
23
23
|
export function redirectStdoutLogging() {
|
|
24
24
|
const original = { log: console.log, info: console.info };
|
|
25
|
-
const toStderr = (...args) => console.error(...args);
|
|
26
25
|
console.log = toStderr;
|
|
27
26
|
console.info = toStderr;
|
|
28
27
|
return () => {
|
|
@@ -30,6 +29,9 @@ export function redirectStdoutLogging() {
|
|
|
30
29
|
console.info = original.info;
|
|
31
30
|
};
|
|
32
31
|
}
|
|
32
|
+
function toStderr(...args) {
|
|
33
|
+
console.error(...args);
|
|
34
|
+
}
|
|
33
35
|
/**
|
|
34
36
|
* A stdio client has no config volume to mount and no way to see a startup
|
|
35
37
|
* error, so an absent file is a warning and an empty hub — `list_servers`
|
package/dist/subscriptions.js
CHANGED
|
@@ -63,6 +63,14 @@ export const DEBOUNCE_MS = nonNegativeIntegerEnv('MCP_SUBSCRIPTION_DEBOUNCE_MS',
|
|
|
63
63
|
* rather than dressed up as the graceful variant.
|
|
64
64
|
*/
|
|
65
65
|
export const MAX_STREAM_MS = nonNegativeIntegerEnv('MCP_SUBSCRIPTION_MAX_MS', 30 * 60_000);
|
|
66
|
+
/**
|
|
67
|
+
* Distinct events one debounce window may hold before it is flushed early.
|
|
68
|
+
*
|
|
69
|
+
* The window is keyed by event kind and, for `resources/updated`, by URI — and
|
|
70
|
+
* the URI is the child's. A child announcing a million distinct URIs inside one
|
|
71
|
+
* window would otherwise be a million map entries the hub holds for it.
|
|
72
|
+
*/
|
|
73
|
+
const MAX_PENDING_EVENTS = 1024;
|
|
66
74
|
/** True unless an operator said otherwise, globally or for this server. */
|
|
67
75
|
export function subscriptionsAllowed(config) {
|
|
68
76
|
return SUBSCRIPTIONS_ENABLED && config.subscriptions !== 'off';
|
|
@@ -169,6 +177,8 @@ export class SubscriptionRegistry {
|
|
|
169
177
|
this.deliver(event);
|
|
170
178
|
return;
|
|
171
179
|
}
|
|
180
|
+
if (this.pending.size >= MAX_PENDING_EVENTS)
|
|
181
|
+
this.flush();
|
|
172
182
|
this.pending.set(event.kind === 'resource_updated' ? `${event.kind}:${event.uri}` : event.kind, event);
|
|
173
183
|
if (this.timer)
|
|
174
184
|
return;
|
|
@@ -198,6 +208,7 @@ export class SubscriptionRegistry {
|
|
|
198
208
|
this.publish({ kind: 'resource_updated', uri });
|
|
199
209
|
}
|
|
200
210
|
flush() {
|
|
211
|
+
clearTimeout(this.timer);
|
|
201
212
|
this.timer = undefined;
|
|
202
213
|
const events = [...this.pending.values()];
|
|
203
214
|
this.pending.clear();
|
package/dist/supervisor.js
CHANGED
|
@@ -6,12 +6,13 @@ import { MAX_TOOL_LIST_PAGES, MAX_TOOLS, MAX_TOOL_METADATA_BYTES, jsonSize } fro
|
|
|
6
6
|
import { BACKOFF_INITIAL_MS, BACKOFF_MAX_MS, BACKOFF_RESET_AFTER_MS, IDLE_SWEEP_INTERVAL_MS, MAX_UNUSED_RESTARTS, PING_INTERVAL_MS, PING_TIMEOUT_MS, WAKE_TIMEOUT_MS } from './timings.js';
|
|
7
7
|
import { SocketTransport } from './transports/socket.js';
|
|
8
8
|
import { DockerTransport } from './transports/docker.js';
|
|
9
|
+
import { setTransportHandlers } from './transports/stream.js';
|
|
9
10
|
import { DockerClient, parseSandboxDockerHost } from './sandbox/docker-client.js';
|
|
10
11
|
import { UpstreamAuth, UpstreamLoginRequiredError } from './upstream/auth.js';
|
|
11
|
-
import { credentialFingerprint } from './upstream/provider.js';
|
|
12
12
|
import { ToolCache } from './tool-cache.js';
|
|
13
13
|
import { filterTools, hasToolFilter, unmatchedPatterns } from './tool-filter.js';
|
|
14
14
|
import { subscriptionsAllowed } from './subscriptions.js';
|
|
15
|
+
import { logSafe } from './auth/text.js';
|
|
15
16
|
/**
|
|
16
17
|
* Whether a failure is one a restart could fix.
|
|
17
18
|
*
|
|
@@ -87,13 +88,15 @@ export function setDockerClient(client) {
|
|
|
87
88
|
function sameFilter(a, b) {
|
|
88
89
|
if (!a)
|
|
89
90
|
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
91
|
return ((a.toolsListChanged ?? false) === (b.toolsListChanged ?? false) &&
|
|
94
92
|
(a.promptsListChanged ?? false) === (b.promptsListChanged ?? false) &&
|
|
95
93
|
(a.resourcesListChanged ?? false) === (b.resourcesListChanged ?? false) &&
|
|
96
|
-
|
|
94
|
+
subscriptionUris(a) === subscriptionUris(b));
|
|
95
|
+
}
|
|
96
|
+
/** JSON rather than a joined string: with a plain separator, ['a b'] and
|
|
97
|
+
* ['a', 'b'] compare equal, and the hub would skip a reconcile it owed. */
|
|
98
|
+
function subscriptionUris(filter) {
|
|
99
|
+
return JSON.stringify((filter.resourceSubscriptions ?? []).toSorted());
|
|
97
100
|
}
|
|
98
101
|
/** Whether a filter asks for nothing at all, in which case nothing is held upstream. */
|
|
99
102
|
function emptyFilter(filter) {
|
|
@@ -349,7 +352,7 @@ export class ManagedServer {
|
|
|
349
352
|
// question on, not answer it.
|
|
350
353
|
inputRequired: { autoFulfill: false }
|
|
351
354
|
});
|
|
352
|
-
transport
|
|
355
|
+
setTransportHandlers(transport, { onclose: () => this.onExit(this.exitReason(), generation) });
|
|
353
356
|
try {
|
|
354
357
|
await client.connect(transport);
|
|
355
358
|
}
|
|
@@ -371,7 +374,9 @@ export class ManagedServer {
|
|
|
371
374
|
// The start itself opens a full idle window, so a pre-warmed server is not
|
|
372
375
|
// swept away just before the tool call it was warmed for.
|
|
373
376
|
this.lastUsedAt = this.startedAt;
|
|
374
|
-
|
|
377
|
+
// The child's declared identity, on its way into a file LOG_FILE mirrors
|
|
378
|
+
// and fail2ban reads: escaped and bounded like any other stranger's text.
|
|
379
|
+
console.log(`[${this.name}] up (${logSafe(this.serverInfo?.name ?? 'unknown', 100)} ${logSafe(this.serverInfo?.version ?? '', 40)})`.trim());
|
|
375
380
|
this.resolveWakeWaiters();
|
|
376
381
|
if (this.capabilities?.tools) {
|
|
377
382
|
client.setNotificationHandler('notifications/tools/list_changed', () => {
|
|
@@ -444,7 +449,7 @@ export class ManagedServer {
|
|
|
444
449
|
}
|
|
445
450
|
this.reconciling = true;
|
|
446
451
|
void this.runReconcile()
|
|
447
|
-
.catch(error => console.error(`[${this.name}] could not update subscriptions: ${error.message}`))
|
|
452
|
+
.catch(error => console.error(`[${this.name}] could not update subscriptions: ${logSafe(error.message, 500)}`))
|
|
448
453
|
.finally(() => {
|
|
449
454
|
this.reconciling = false;
|
|
450
455
|
if (!this.reconcileQueued)
|
|
@@ -563,7 +568,7 @@ export class ManagedServer {
|
|
|
563
568
|
this.options.persist?.(this);
|
|
564
569
|
}
|
|
565
570
|
catch (error) {
|
|
566
|
-
console.error(`[${this.name}] failed to list tools: ${error.message}`);
|
|
571
|
+
console.error(`[${this.name}] failed to list tools: ${logSafe(error.message, 500)}`);
|
|
567
572
|
}
|
|
568
573
|
}
|
|
569
574
|
async checkAlive() {
|
|
@@ -599,7 +604,7 @@ export class ManagedServer {
|
|
|
599
604
|
await client.ping({ timeout: PING_TIMEOUT_MS });
|
|
600
605
|
}
|
|
601
606
|
catch (error) {
|
|
602
|
-
console.error(`[${this.name}] ping failed, restarting: ${error.message}`);
|
|
607
|
+
console.error(`[${this.name}] ping failed, restarting: ${logSafe(error.message, 500)}`);
|
|
603
608
|
// close() triggers transport.onclose -> onExit -> restart with backoff.
|
|
604
609
|
// Already-closed transports reject here; onExit has then run regardless.
|
|
605
610
|
await client.close().catch(() => { });
|
|
@@ -629,7 +634,7 @@ export class ManagedServer {
|
|
|
629
634
|
this.state = 'unauthorized';
|
|
630
635
|
this.lastError = reason;
|
|
631
636
|
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}`);
|
|
637
|
+
console.error(`[${this.name}] unauthorized (${logSafe(reason, 500)}); run: mcp-hub-admin upstream login ${this.name}`);
|
|
633
638
|
return;
|
|
634
639
|
}
|
|
635
640
|
this.state = 'down';
|
|
@@ -641,12 +646,12 @@ export class ManagedServer {
|
|
|
641
646
|
if (this.onDemand && this.restartsSinceUse > (this.options.maxUnusedRestarts ?? MAX_UNUSED_RESTARTS)) {
|
|
642
647
|
// A crash-looping server nobody asks for would occupy the machine
|
|
643
648
|
// 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`);
|
|
649
|
+
console.error(`[${this.name}] down (${logSafe(reason, 500)}), giving up until next use after ${this.restartsSinceUse - 1} failed restarts`);
|
|
645
650
|
this.state = 'sleeping';
|
|
646
651
|
this.rejectWakeWaiters(new Error(`Server "${this.name}" failed to start: ${reason}`));
|
|
647
652
|
return;
|
|
648
653
|
}
|
|
649
|
-
console.error(`[${this.name}] down (${reason}), restarting in ${Math.round(this.backoffMs / 1000)}s`);
|
|
654
|
+
console.error(`[${this.name}] down (${logSafe(reason, 500)}), restarting in ${Math.round(this.backoffMs / 1000)}s`);
|
|
650
655
|
this.restartTimer = setTimeout(() => {
|
|
651
656
|
this.restarts++;
|
|
652
657
|
void this.start();
|
|
@@ -733,7 +738,10 @@ export class UpstreamAuthRegistry {
|
|
|
733
738
|
if (!config.oauth)
|
|
734
739
|
return undefined;
|
|
735
740
|
const auth = new UpstreamAuth(name, config, this.store, this.externalUrl);
|
|
736
|
-
|
|
741
|
+
// Manager identity includes live headers and authentication settings. The
|
|
742
|
+
// persisted credential fingerprint intentionally survives secret rotation;
|
|
743
|
+
// it must not keep the old in-memory configuration alive as well.
|
|
744
|
+
const fingerprint = JSON.stringify(config);
|
|
737
745
|
const existing = this.managers.get(name);
|
|
738
746
|
if (existing?.fingerprint === fingerprint)
|
|
739
747
|
return existing.auth;
|
|
@@ -899,7 +907,7 @@ export class Supervisor {
|
|
|
899
907
|
if (!managed || managed.state !== 'sleeping')
|
|
900
908
|
continue;
|
|
901
909
|
}
|
|
902
|
-
console.log(`mcp-hub: removing orphaned sandbox container ${container.name}`);
|
|
910
|
+
console.log(`mcp-hub: removing orphaned sandbox container ${logSafe(container.name)}`);
|
|
903
911
|
await dockerClient().removeContainer(container.name);
|
|
904
912
|
}
|
|
905
913
|
}
|