@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +26 -11
  3. package/dist/admin.js +4 -4
  4. package/dist/auth/address.js +1 -1
  5. package/dist/auth/oidc/interactions.js +40 -25
  6. package/dist/auth/oidc/provider.js +12 -0
  7. package/dist/auth/password.js +50 -0
  8. package/dist/auth/pinned-fetch.js +30 -0
  9. package/dist/auth/registration.js +5 -5
  10. package/dist/auth/session.js +32 -5
  11. package/dist/auth/signed-token.js +6 -2
  12. package/dist/auth/store.js +37 -29
  13. package/dist/child-text.js +39 -0
  14. package/dist/config.js +1 -5
  15. package/dist/docker-proxy/index.js +9 -2
  16. package/dist/docker-proxy/secrets-watcher.js +1 -1
  17. package/dist/docker-proxy/secrets.js +5 -1
  18. package/dist/docker-proxy/server.js +14 -0
  19. package/dist/elicitation.js +0 -0
  20. package/dist/forward.js +0 -0
  21. package/dist/hub.js +14 -7
  22. package/dist/index.js +19 -2
  23. package/dist/stdio.js +3 -1
  24. package/dist/subscriptions.js +11 -0
  25. package/dist/supervisor.js +23 -15
  26. package/dist/transports/docker.js +11 -9
  27. package/dist/transports/stream.js +10 -0
  28. package/dist/upstream/auth.js +15 -8
  29. package/dist/upstream/provider.js +1 -1
  30. package/dist/upstream/routes.js +27 -22
  31. package/package.json +6 -3
  32. package/dist/admin.js.map +0 -1
  33. package/dist/auth/address.js.map +0 -1
  34. package/dist/auth/api-tokens.js.map +0 -1
  35. package/dist/auth/cimd.js.map +0 -1
  36. package/dist/auth/consent-page.js.map +0 -1
  37. package/dist/auth/headers.js.map +0 -1
  38. package/dist/auth/login-page.js.map +0 -1
  39. package/dist/auth/oidc/adapter.js.map +0 -1
  40. package/dist/auth/oidc/interactions.js.map +0 -1
  41. package/dist/auth/oidc/mount.js.map +0 -1
  42. package/dist/auth/oidc/provider.js.map +0 -1
  43. package/dist/auth/oidc/quirks.js.map +0 -1
  44. package/dist/auth/oidc/verifier.js.map +0 -1
  45. package/dist/auth/page.js.map +0 -1
  46. package/dist/auth/pinned-fetch.js.map +0 -1
  47. package/dist/auth/protected-resource.js.map +0 -1
  48. package/dist/auth/rate-limit.js.map +0 -1
  49. package/dist/auth/redirect-uri.js.map +0 -1
  50. package/dist/auth/registration.js.map +0 -1
  51. package/dist/auth/resource.js.map +0 -1
  52. package/dist/auth/session.js.map +0 -1
  53. package/dist/auth/signed-token.js.map +0 -1
  54. package/dist/auth/store.js.map +0 -1
  55. package/dist/auth/text.js.map +0 -1
  56. package/dist/config.js.map +0 -1
  57. package/dist/docker-proxy/index.js.map +0 -1
  58. package/dist/docker-proxy/policy.js.map +0 -1
  59. package/dist/docker-proxy/secrets-watcher.js.map +0 -1
  60. package/dist/docker-proxy/secrets.js.map +0 -1
  61. package/dist/docker-proxy/server.js.map +0 -1
  62. package/dist/elicitation.js.map +0 -1
  63. package/dist/forward.js.map +0 -1
  64. package/dist/health.js.map +0 -1
  65. package/dist/hub.js.map +0 -1
  66. package/dist/index.js.map +0 -1
  67. package/dist/limits.js.map +0 -1
  68. package/dist/logfile.js.map +0 -1
  69. package/dist/main-module.js.map +0 -1
  70. package/dist/mcp-limits.js.map +0 -1
  71. package/dist/mount-check.js.map +0 -1
  72. package/dist/proxy.js.map +0 -1
  73. package/dist/sandbox/container-spec.js.map +0 -1
  74. package/dist/sandbox/docker-client.js.map +0 -1
  75. package/dist/sandbox/policy-protocol.js.map +0 -1
  76. package/dist/stdio.js.map +0 -1
  77. package/dist/subscriptions.js.map +0 -1
  78. package/dist/supervisor.js.map +0 -1
  79. package/dist/timings.js.map +0 -1
  80. package/dist/tool-cache.js.map +0 -1
  81. package/dist/tool-filter.js.map +0 -1
  82. package/dist/transports/docker.js.map +0 -1
  83. package/dist/transports/socket.js.map +0 -1
  84. package/dist/transports/stream.js.map +0 -1
  85. package/dist/upstream/auth.js.map +0 -1
  86. package/dist/upstream/login.js.map +0 -1
  87. package/dist/upstream/provider.js.map +0 -1
  88. package/dist/upstream/routes.js.map +0 -1
  89. package/dist/version.js.map +0 -1
@@ -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
- /** The verified cookie value, or undefined when absent, forged or expired. */
23
- export function readSessionCookie(cookieHeader, secret) {
24
- const match = cookieHeader?.match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`));
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
- const value = decodeURIComponent(match[1]);
28
- const [expires, signature] = value.split('.');
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
- if (signature.length !== expected.length)
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))
@@ -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.sort((a, b) => (records[a].expiresAt || Infinity) - (records[b].expiresAt || Infinity));
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
- .sort((a, b) => (a.client_id_issued_at ?? 0) - (b.client_id_issued_at ?? 0));
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
- .sort((a, b) => (a.client_id_issued_at ?? 0) - (b.client_id_issued_at ?? 0));
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
- try {
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
- const socketMode = Number.parseInt(env('SOCKET_MODE', '0660'), 8);
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).sort(([a], [b]) => a.localeCompare(b)));
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
- const result = {};
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, {
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.message}`);
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
- description: s.serverInfo?.title ?? s.serverInfo?.name ?? '',
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.message}`);
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.message}`);
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.message}`);
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 "${entry}" is not a URL`);
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 "${entry}" must be a bare https origin, e.g. https://chatgpt.com`);
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`
@@ -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();
@@ -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
- uris(a) === uris(b));
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.onclose = () => this.onExit(this.exitReason(), generation);
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
- console.log(`[${this.name}] up (${this.serverInfo?.name ?? 'unknown'} ${this.serverInfo?.version ?? ''})`.trim());
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
- const fingerprint = credentialFingerprint(auth.identity);
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
  }