mcp-google-multi 6.0.0-beta.3 → 6.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +5 -5
  2. package/dist/accounts.d.ts +3 -3
  3. package/dist/accounts.js +18 -31
  4. package/dist/arg-normalize.js +15 -35
  5. package/dist/arg-strict.js +8 -28
  6. package/dist/auth.d.ts +1 -1
  7. package/dist/auth.js +5 -10
  8. package/dist/boot-gates.d.ts +1 -1
  9. package/dist/boot-gates.js +3 -5
  10. package/dist/cimd-cache.js +5 -11
  11. package/dist/client-config.d.ts +2 -2
  12. package/dist/client-config.js +8 -14
  13. package/dist/client.js +0 -0
  14. package/dist/compose.d.ts +8 -1
  15. package/dist/compose.js +11 -11
  16. package/dist/config-file.js +7 -12
  17. package/dist/discover.js +4 -9
  18. package/dist/discovery-client.js +14 -33
  19. package/dist/doctor.d.ts +3 -3
  20. package/dist/doctor.js +12 -18
  21. package/dist/env-load.js +2 -5
  22. package/dist/executor.js +2 -4
  23. package/dist/fanout.js +8 -17
  24. package/dist/fs-atomic.d.ts +0 -3
  25. package/dist/fs-atomic.js +6 -10
  26. package/dist/hint-provider.js +4 -10
  27. package/dist/http-config.d.ts +2 -2
  28. package/dist/http-config.js +3 -7
  29. package/dist/http-transport.d.ts +5 -5
  30. package/dist/http-transport.js +39 -92
  31. package/dist/identity.d.ts +2 -2
  32. package/dist/identity.js +6 -9
  33. package/dist/index.js +19 -31
  34. package/dist/master-key.d.ts +3 -3
  35. package/dist/master-key.js +11 -22
  36. package/dist/mcp-token.d.ts +3 -13
  37. package/dist/mcp-token.js +16 -32
  38. package/dist/metrics-cli.d.ts +1 -1
  39. package/dist/metrics-cli.js +1 -1
  40. package/dist/metrics-tap.js +2 -5
  41. package/dist/migrate-config.d.ts +1 -2
  42. package/dist/migrate-config.js +3 -5
  43. package/dist/net-tuning.js +2 -4
  44. package/dist/oauth-as.d.ts +2 -2
  45. package/dist/oauth-as.js +32 -73
  46. package/dist/oauth-consent.js +4 -10
  47. package/dist/outbound-allowlist.js +4 -9
  48. package/dist/reauth-hint.js +2 -5
  49. package/dist/registry-transfer.d.ts +1 -1
  50. package/dist/registry-transfer.js +5 -13
  51. package/dist/registry.d.ts +1 -9
  52. package/dist/registry.js +38 -66
  53. package/dist/scope-catalog.d.ts +5 -0
  54. package/dist/scope-catalog.js +17 -7
  55. package/dist/scope-observability.d.ts +2 -3
  56. package/dist/scope-observability.js +8 -14
  57. package/dist/services.d.ts +3 -0
  58. package/dist/services.js +5 -10
  59. package/dist/ssrf-guard.js +11 -22
  60. package/dist/tenant-purge.js +2 -4
  61. package/dist/token-store.js +0 -0
  62. package/dist/tools/_coerce.d.ts +4 -4
  63. package/dist/tools/_errors.js +22 -39
  64. package/dist/tools/_local-files.js +4 -8
  65. package/dist/tools/account-wizard.d.ts +4 -3
  66. package/dist/tools/account-wizard.js +48 -54
  67. package/dist/tools/accounts-tool.d.ts +1 -1
  68. package/dist/tools/accounts-tool.js +1 -1
  69. package/dist/tools/admin.js +2 -3
  70. package/dist/tools/analytics.js +4 -5
  71. package/dist/tools/calendar.js +4 -7
  72. package/dist/tools/chat.js +2 -3
  73. package/dist/tools/contacts.js +8 -9
  74. package/dist/tools/docs.js +4 -9
  75. package/dist/tools/drive.js +18 -27
  76. package/dist/tools/forms.js +2 -3
  77. package/dist/tools/gmail-mime.d.ts +1 -1
  78. package/dist/tools/gmail-mime.js +7 -12
  79. package/dist/tools/gmail.d.ts +2 -2
  80. package/dist/tools/gmail.js +26 -40
  81. package/dist/tools/google-api.js +5 -7
  82. package/dist/tools/meet.js +2 -3
  83. package/dist/tools/searchconsole.js +2 -3
  84. package/dist/tools/sheets.js +2 -3
  85. package/dist/tools/slides.js +2 -3
  86. package/dist/tools/tasks.js +2 -3
  87. package/dist/trim.js +2 -3
  88. package/dist/usage-metrics.js +5 -12
  89. package/dist/write-control.d.ts +4 -8
  90. package/dist/write-control.js +3 -4
  91. package/package.json +21 -21
@@ -1,30 +1,21 @@
1
1
  import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
2
- // node:http server, the route table, the front guard (Host / Origin / DNS-rebind),
3
- // and the stateless per-request /mcp dispatch. The OAuth AS endpoints and Bearer
4
- // verification are a seam filled by B13 (oauth-authorization-server); B12 ships a
5
- // loopback-owner authenticator so the local-HTTP model works before the AS lands.
6
2
  import { createServer } from 'node:http';
7
3
  import { withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
8
4
  import { logSafe } from './trim.js';
9
5
  // A host wrapping the log callbacks it hands core needs this, and trim has no package export.
10
6
  export { logSafe } from './trim.js';
11
7
  // A hung handler that keeps the connection open would otherwise hold the global
12
- // serialize() lock forever. Generous by default so slow-but-valid calls (large
13
- // Drive exports, fan-out) still finish; the point is only to guarantee release.
8
+ // serialize() lock forever; generous by default so slow-but-valid calls still finish.
14
9
  const DISPATCH_TIMEOUT_DEFAULT = 120_000;
15
- /** MCP 2025-06-18 dropped JSON-RPC batching, and the SDK screens every frame
16
- * of a batch synchronously in one tick: without a cap one body multiplies the
17
- * per-call work by however many frames fit in it. */
10
+ // Without a cap, one body multiplies per-call work by however many
11
+ // JSON-RPC frames the SDK's synchronous batch screening finds in it.
18
12
  const MAX_BATCH_FRAMES = 16;
19
- // A resolveServer refusal's body reaches the wire verbatim (H3 PR1): validated
20
- // so a host cannot accidentally leak an operator path/env var through a wrongly
21
- // shaped slug or an overlong message. No new slug literal enters core itself —
22
- // the slug is always the host's own value.
13
+ // A refusal's body reaches the wire verbatim, so it's validated: no new slug
14
+ // literal enters core, and an overlong message can't leak an operator path/env var.
23
15
  const REFUSAL_SLUG_RE = /^[a-z][a-z0-9_]{0,39}$/;
24
16
  const MAX_REFUSAL_MESSAGE_CHARS = 300;
25
- // C0/C1 controls, format chars (includes bidi overrides like U+202E and
26
- // zero-width joiners) and the line/paragraph separators; ordinary non-ASCII
27
- // letters stay allowed.
17
+ // C0/C1 controls, format chars (bidi overrides, zero-width joiners) and
18
+ // line/paragraph separators; ordinary non-ASCII letters stay allowed.
28
19
  const NON_PRINTABLE_RE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
29
20
  function isValidRefusal(r) {
30
21
  return (typeof r.error === 'string' &&
@@ -42,7 +33,7 @@ export function parseOwnerEmails(env = process.env) {
42
33
  }
43
34
  /** Front guard: an Origin, if present, must be allowlisted; a Host must be
44
35
  * allowlisted. Absent Origin passes (native/CLI/backend clients — the claude.ai
45
- * connector calls /mcp server-to-server with no Origin, BV-4). */
36
+ * connector calls /mcp server-to-server with no Origin). */
46
37
  export function originAllowed(origin, allowed) {
47
38
  if (!origin)
48
39
  return true;
@@ -81,25 +72,17 @@ export function jsonRpcMethod(body) {
81
72
  /** setTimeout fires at once for a delay above this, so a longer grace would
82
73
  * cut every connection immediately. */
83
74
  const MAX_GRACE_MS = 2 ** 31 - 1;
84
- // A lingering close: a socket destroyed while its client is still sending (a
85
- // body pipelined after the close) is reset by the kernel, which drops whatever
86
- // of the last response is still unsent. So the socket only ends its side and
87
- // waits for the client's FIN, or for the grace to end.
75
+ // A lingering close (ends the socket, never destroys it); see docs/internals.md (Graceful close).
88
76
  function endAfterFlush(socket) {
89
77
  if (socket.destroyed || socket.writableEnded)
90
78
  return;
91
- // Node arms a keep-alive timer when a response finishes and destroys the
92
- // socket when it fires, which would cut a slow reader's unread tail just as
93
- // an early destroy does. Only the client's FIN or the grace ends it now.
79
+ // Disarm the keep-alive timer Node arms on finish, or it destroys the
80
+ // socket ~6s later and cuts a slow reader's unread tail the same way an early destroy does.
94
81
  socket.setTimeout(0);
95
82
  socket.end();
96
83
  }
97
- // A request that arrives while closing is never answered, so Node keeps it
98
- // until the socket closes, and a flood pipelined behind a held response would
99
- // pile up without limit. Reading stops for good instead, which lets TCP flow
100
- // control stop the client. Node resumes a socket after every parsed request
101
- // and on drain, so resume becomes a no-op. Such a socket never sees the
102
- // client's FIN either: the grace ends it.
84
+ // Stops reading for good so TCP flow control stops the client, instead of
85
+ // piling up a flood pipelined behind a held response; see docs/internals.md (Graceful close).
103
86
  function stopReading(socket) {
104
87
  socket.pause();
105
88
  socket.resume = () => socket;
@@ -117,18 +100,12 @@ function pathOf(req) {
117
100
  export class HttpTransportHost {
118
101
  opts;
119
102
  httpServer;
120
- // Serialize the connect→dispatch critical section PER SERVER: a McpServer
121
- // captures its transport per request (protocol.js), but the gap between
122
- // connect() and the dispatch capturing it would still race under true
123
- // concurrency, so all traffic to ONE server runs under one mutex. The free
124
- // core has one lane (the boot server); with resolveServer each resolved
125
- // server gets its own, however subjects map onto servers.
103
+ // Per-server dispatch mutex; see docs/internals.md.
126
104
  locks = new Map();
127
105
  // Requests holding a lane slot, per server; used only with maxQueuedPerLane.
128
106
  depth = new Map();
129
- // What a graceful close waits on: open sockets, per socket the requests it
130
- // has accepted whose response has not settled and the last of them, and
131
- // every handle() that has not settled.
107
+ // What a graceful close waits on: open sockets and every handle() not yet
108
+ // settled; see docs/internals.md (Graceful close).
132
109
  sockets = new Set();
133
110
  pending = new Map();
134
111
  // Sockets that have accepted a request: nothing pending means the last
@@ -145,9 +122,8 @@ export class HttpTransportHost {
145
122
  const run = prev.then(fn, fn);
146
123
  const tail = run.then(() => undefined, () => undefined);
147
124
  this.locks.set(key, tail);
148
- // An idle lane drops out, so the map holds only in-flight subjects rather
149
- // than every subject ever seen. A request that chained on this tail has
150
- // already replaced it, so the identity check never frees a live lane.
125
+ // An idle lane drops out of the map; the identity check below skips a
126
+ // later request that already replaced this tail, so it never frees a live lane.
151
127
  void tail.then(() => {
152
128
  if (this.locks.get(key) === tail)
153
129
  this.locks.delete(key);
@@ -161,9 +137,8 @@ export class HttpTransportHost {
161
137
  const { config } = this.opts;
162
138
  const server = createServer((req, res) => {
163
139
  const socket = req.socket;
164
- // Once closing, a connection ends after the last request it accepted
165
- // before the close, so one arriving now is never handled (RFC 9112 9.6).
166
- // Its body is left unread: nothing reads the socket again.
140
+ // Once closing, a request arriving now is never handled (RFC 9112 §9.6)
141
+ // and its body is left unread.
167
142
  if (this.closing) {
168
143
  stopReading(socket);
169
144
  return;
@@ -190,10 +165,8 @@ export class HttpTransportHost {
190
165
  };
191
166
  res.once('finish', settle);
192
167
  res.once('close', settle);
193
- // Never rejects: close() waits on it, and a catch-all that itself threw
194
- // must neither reject close() nor surface as an unhandled rejection.
195
- // The thrown message can name server paths (a lock timeout does), so it
196
- // goes only to the host's log.
168
+ // Never rejects (close() waits on it); the thrown message can name
169
+ // server paths, so it goes only to the host's log.
197
170
  const done = this.handle(req, res)
198
171
  .catch((e) => {
199
172
  try {
@@ -265,20 +238,13 @@ export class HttpTransportHost {
265
238
  }
266
239
  async closeGracefully(s, graceMs) {
267
240
  this.closing = true;
268
- // Node's close() also destroys every connection it deems idle, and that
269
- // includes one whose response has ended but is still flushing: a large
270
- // body would be cut short. Idle sockets are closed below instead.
241
+ // Node's close() also destroys idle connections, which would cut short a
242
+ // large response still flushing; idle sockets are closed below instead.
271
243
  const nodeIdleCloser = s.closeIdleConnections;
272
244
  s.closeIdleConnections = () => undefined;
273
245
  const closed = new Promise((resolve) => s.close(() => resolve()));
274
246
  s.closeIdleConnections = nodeIdleCloser;
275
- // Every request accepted before now is answered, and a connection ends
276
- // after the last of them: that one says so if it has not started, and the
277
- // settle hook in start() ends the socket once it finishes. Node itself
278
- // destroys a socket once a Connection: close response is flushed, which
279
- // resets it just as endAfterFlush explains, so that end lingers too. A
280
- // socket with nothing pending may still be delivering the tail of its
281
- // last response, so only one that never accepted a request is destroyed.
247
+ // Socket bookkeeping below; see docs/internals.md (Graceful close).
282
248
  for (const socket of this.sockets) {
283
249
  const entry = this.pending.get(socket);
284
250
  if (!entry && !this.served.has(socket)) {
@@ -357,10 +323,7 @@ export class HttpTransportHost {
357
323
  }
358
324
  if (!this.frontGuard(req, res))
359
325
  return;
360
- // Registered before anything awaits: a client that leaves while its request
361
- // waits on the lane must not have it dispatched once its turn comes. A
362
- // response queued behind another on its connection never emits 'close', so
363
- // the socket is checked too.
326
+ // Registered before anything awaits (client-gone skip); see docs/internals.md.
364
327
  let gone = false;
365
328
  res.once('close', () => (gone = true));
366
329
  const socket = req.socket;
@@ -375,8 +338,8 @@ export class HttpTransportHost {
375
338
  return;
376
339
  }
377
340
  const sub = auth.sub ?? 'owner';
378
- // Tenant resolution (the seam EE fills): map the verified subject to ITS
379
- // server. Absent resolver = the one boot server for every subject.
341
+ // Tenant resolution (a multi-tenant host fills this seam): map the
342
+ // verified subject to ITS server. Absent resolver = the one boot server for every subject.
380
343
  let target = {
381
344
  server: this.opts.server,
382
345
  argShapeFor: this.opts.argShapeFor,
@@ -400,8 +363,7 @@ export class HttpTransportHost {
400
363
  return;
401
364
  }
402
365
  // An invalid shape falls through to today's exact answer rather than
403
- // forwarding something unvalidated; a refusal never reaches dispatch
404
- // either way.
366
+ // forwarding something unvalidated.
405
367
  resolved = null;
406
368
  }
407
369
  target = resolved;
@@ -437,9 +399,8 @@ export class HttpTransportHost {
437
399
  }
438
400
  }
439
401
  async admitted(req, res, target, sub, clientGone) {
440
- // Thread the verified subject to tool handlers: the Node transport forwards
441
- // req.auth verbatim as ctx.http.authInfo. Token/clientId stay empty — the
442
- // bearer value must not re-enter the dispatch path via handler context.
402
+ // Thread the verified subject to tool handlers via ctx.http.authInfo;
403
+ // token/clientId stay empty so the bearer value never re-enters the dispatch path.
443
404
  req.auth = {
444
405
  token: '',
445
406
  clientId: '',
@@ -457,28 +418,16 @@ export class HttpTransportHost {
457
418
  if (Array.isArray(body) && body.length > maxFrames) {
458
419
  return this.fail(res, 400, 'batch_too_large', `a JSON-RPC batch may hold at most ${maxFrames} messages`);
459
420
  }
460
- // Host/Origin are enforced by the front guard above (uniformly for /mcp and
461
- // the mounted AS routes), so the SDK's own DNS-rebind guard is disabled: its
462
- // exact-Host match is stricter than the front guard and would 403 valid
463
- // Hosts (double enforcement, differing rules).
421
+ // The SDK's own DNS-rebind guard is disabled: Host/Origin are already
422
+ // enforced above, and its stricter exact-Host match would 403 valid Hosts.
464
423
  const transport = new NodeStreamableHTTPServerTransport({
465
424
  sessionIdGenerator: undefined,
466
425
  enableJsonResponse: true,
467
426
  enableDnsRebindingProtection: false,
468
427
  });
469
- // The shared McpServer holds exactly one connected transport at a time, so
470
- // connect + dispatch + DISCONNECT all run inside the mutex: the next request
471
- // can never hit "Already connected". The serialized section is guaranteed to
472
- // settle three ways — the handler finishes, the client disconnects (res
473
- // 'close'), or a dispatch deadline fires — so neither a client that holds the
474
- // connection open nor a hung upstream can wedge the lock. The transport is
475
- // closed in a finally (which resets server._transport) before the lock
476
- // releases. (A shared initialized-state persists across stateless requests;
477
- // benign for the single-owner design.)
428
+ // connect + dispatch + disconnect run inside the per-server mutex, keyed
429
+ // on the server identity, never the subject; see docs/internals.md.
478
430
  const deadlineMs = this.opts.dispatchTimeoutMs ?? DISPATCH_TIMEOUT_DEFAULT;
479
- // The lock lane is the SERVER identity, never the subject: two subjects
480
- // reaching one server (the boot server, or a resolver mapping several
481
- // subjects to one) would otherwise race it through the connect gap.
482
431
  const { server: mcpServer, argShapeFor, strictArgs, validationEnvelope } = target;
483
432
  await this.serializeFor(mcpServer, async () => {
484
433
  if (clientGone()) {
@@ -491,16 +440,14 @@ export class HttpTransportHost {
491
440
  timer = setTimeout(() => resolve('timeout'), deadlineMs);
492
441
  timer.unref?.();
493
442
  });
494
- // Same composition as the stdio leg: the envelope rewrite is innermost,
495
- // so the tap above it still classifies the original validation prose.
443
+ // Envelope rewrite innermost, as on stdio; see docs/internals.md (Argument normalization).
496
444
  const enveloped = withValidationEnvelope(transport, validationEnvelope ?? {});
497
445
  const tapped = this.opts.metricsTap ? this.opts.metricsTap(enveloped) : enveloped;
498
446
  await mcpServer.connect(argShapeFor || strictArgs
499
447
  ? withArgNormalization(tapped, argShapeFor ?? (() => undefined), this.opts.log, this.opts.onArgRename, strictArgs)
500
448
  : tapped);
501
- // Reflect the dispatch into a non-rejecting arm: if the deadline wins the
502
- // race, an orphaned handler settling later must not surface as an unhandled
503
- // rejection — but a genuine dispatch error still propagates (rethrown below).
449
+ // Non-rejecting arm: an orphaned handler settling after the deadline wins
450
+ // must not surface as an unhandled rejection; a real error is rethrown below.
504
451
  let dispatchErr;
505
452
  const dispatchArm = transport
506
453
  .handleRequest(req, res, body)
@@ -538,7 +485,7 @@ export class HttpTransportHost {
538
485
  health(res) {
539
486
  const { config, ownerConfigured, version } = this.opts;
540
487
  res.writeHead(200, { 'Content-Type': 'application/json' });
541
- // No secrets, no owner emails (cc-transport-hosting /health rule).
488
+ // No secrets, no owner emails.
542
489
  res.end(JSON.stringify({
543
490
  status: 'ok',
544
491
  transport: config.transport,
@@ -4,8 +4,8 @@ import { hasToken, readToken, updateToken, writeToken } from './token-store.js';
4
4
  import { type Policy, type Transport } from './write-control.js';
5
5
  /**
6
6
  * The one forward-compat seam (frozen public API): the free core builds
7
- * exactly one context with subject "owner"; EE later instantiates N contexts
8
- * from N registries. Nothing here may assume a global current-account.
7
+ * exactly one context with subject "owner"; a multi-tenant host instantiates
8
+ * N contexts from N registries. Nothing here may assume a global current-account.
9
9
  * `subject` is a string (not the literal 'owner') so a context can carry a
10
10
  * tenant id; the free core still always builds 'owner'.
11
11
  */
package/dist/identity.js CHANGED
@@ -2,18 +2,15 @@ import { getAccountSet, getTokenDir, refreshAccountSetIfStale } from './accounts
2
2
  import { makeGetClient } from './client.js';
3
3
  import { createTokenStore } from './token-store.js';
4
4
  import { resolvePolicy } from './write-control.js';
5
- // Only buildIdentityContext mints the owner brand. The subject string alone
6
- // never unlocks the owner-only surfaces (account wizard, host-file tools, the
7
- // operator diagnose report): a hand-built context claiming 'owner' stays a
8
- // non-owner context.
5
+ // Only buildIdentityContext mints the owner brand: the subject string alone
6
+ // never unlocks owner-only surfaces, so a hand-built context claiming 'owner' stays non-owner.
9
7
  const ownerContexts = new WeakSet();
10
8
  export function isOwnerContext(ctx) {
11
9
  return ctx !== undefined && ownerContexts.has(ctx);
12
10
  }
13
11
  export function buildIdentityContext(env = process.env, opts = {}) {
14
- // Real closures over the single-owner state (the SAME factories EE
15
- // instantiates per tenant), bound here to the global registry + token dir —
16
- // behavior-identical to the module-level functions for the free core.
12
+ // The SAME factories a multi-tenant host instantiates per tenant, bound here
13
+ // to the global registry + token dir.
17
14
  const tokenStore = createTokenStore(getTokenDir());
18
15
  const ctx = {
19
16
  subject: 'owner',
@@ -22,8 +19,8 @@ export function buildIdentityContext(env = process.env, opts = {}) {
22
19
  get accounts() {
23
20
  return getAccountSet();
24
21
  },
25
- // The dispatch transport rides into the resolved policy as a reserved seam
26
- // (cc-write-control B14); it does not change any write-control verdict.
22
+ // The dispatch transport rides into the resolved policy as a reserved seam;
23
+ // it does not change any write-control verdict.
27
24
  policy: resolvePolicy(env, { transport: opts.transport }),
28
25
  getClient: makeGetClient('owner', {
29
26
  accounts: getAccountSet,
package/dist/index.js CHANGED
@@ -1,17 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  // First import: triggers accounts.js module load (env files + registry) before
3
- // anything else. Named to also pull the server-only empty-registry guard (BR-4).
3
+ // anything else. Named to also pull the server-only empty-registry guard.
4
4
  import { assertServerAccountsConfigured } from './accounts.js';
5
5
  import { accountsAssertRequired, assertNoEnvAccountsMode, assertNoEnvOptionalScopesMode, isMultiTenantBoot, mtBootGates } from './boot-gates.js';
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import path from 'node:path';
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
10
- import { McpServer } from "@modelcontextprotocol/server";
11
10
  import { resolveDiscoveryMode } from './registry.js';
12
11
  import { isAllowed, describePolicy } from './write-control.js';
13
12
  import { buildIdentityContext } from './identity.js';
14
- import { buildRegistry, requestHooksFor } from './compose.js';
13
+ import { buildRegistry, createMcpServer, requestHooksFor } from './compose.js';
15
14
  import { registerSetupPrompt } from './setup-prompt.js';
16
15
  import { applyNetTuning } from './net-tuning.js';
17
16
  import { withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
@@ -81,7 +80,7 @@ async function main() {
81
80
  if (process.argv.includes('config') && process.argv.includes('check')) {
82
81
  const ctx = buildIdentityContext();
83
82
  const policy = ctx.policy;
84
- const registry = buildRegistry(new McpServer({ name: 'mcp-google-multi', version: pkg.version }), ctx);
83
+ const registry = buildRegistry(createMcpServer(pkg.version), ctx);
85
84
  const cud = registry.tools.filter((t) => t.cud !== 'read');
86
85
  const disabled = cud.filter((t) => !isAllowed(t, policy));
87
86
  const counts = registry.visibleCount();
@@ -140,10 +139,8 @@ async function main() {
140
139
  }
141
140
  const wantStdio = httpCfg.transport === 'stdio' || httpCfg.transport === 'both';
142
141
  const wantHttp = transportIncludesHttp(httpCfg.transport);
143
- // BR-4: the SERVER never boots with an empty registry — stdio always, HTTP
144
- // when single-owner. The bootstrap and diagnostic CLIs above already
145
- // returned. A multi-tenant boot instead refuses the two process-wide env
146
- // vectors that would leak one operator's accounts/scopes into every tenant.
142
+ // The SERVER never boots with an empty registry (stdio always, HTTP when
143
+ // single-owner); a multi-tenant boot instead refuses the two process-wide env vectors.
147
144
  const mt = isMultiTenantBoot();
148
145
  if (accountsAssertRequired(wantStdio, mt))
149
146
  assertServerAccountsConfigured();
@@ -155,9 +152,7 @@ async function main() {
155
152
  // specced "fatal at startup", never mid-dispatch.
156
153
  const { resolveMasterKey } = await import('./master-key.js');
157
154
  resolveMasterKey();
158
- // Local usage metrics: fail-closed resolve, self-announcing source, null
159
- // when off (no wrapper, no dir, nothing initializes). One instance per
160
- // transport so `boots` keys stay honest under `both`.
155
+ // One Metrics instance per transport so `boots` keys stay honest under `both`.
161
156
  const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
162
157
  const metricsState = resolveUsageMetrics(process.env, loadConfigFile()?.usageMetrics, envSrc?.kind === 'file' ? envSrc.file : undefined);
163
158
  if (metricsState.warning)
@@ -187,21 +182,17 @@ async function main() {
187
182
  });
188
183
  }
189
184
  }
190
- // Build one McpServer + registry per transport at boot (P1 / BV gap #4:
191
- // never rebuilt per request); `both` runs the two concurrently.
185
+ // Build one McpServer + registry per transport at boot (never
186
+ // rebuilt per request); `both` runs the two concurrently.
192
187
  if (wantStdio) {
193
- const server = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
188
+ const server = createMcpServer(pkg.version);
194
189
  const stdioMetrics = initMetricsFor('stdio', resolveDiscoveryMode());
195
190
  const stdioCtx = buildIdentityContext(process.env, { transport: 'stdio' });
196
191
  const registry = buildRegistry(server, stdioCtx, undefined, stdioMetrics);
197
192
  registry.installListHandler();
198
193
  registerSetupPrompt(server);
199
194
  const stdioHooks = requestHooksFor(registry, stdioCtx, stdioMetrics);
200
- // Outbound runs outermost-first, so the composition is deliberate: the
201
- // envelope rewrite is INNERMOST (last to touch the frame), the tap sits
202
- // above it (classifying the ORIGINAL validation prose), arg normalization
203
- // outermost. The tap only counts outbound JSON-RPC error frames (no
204
- // handler ran) plus id->tool names.
195
+ // Composition order (outbound runs outermost-first): see docs/internals.md (Argument normalization).
205
196
  let transport = withValidationEnvelope(new StdioServerTransport(), stdioHooks.validationEnvelope);
206
197
  if (stdioMetrics) {
207
198
  const { tapUsageMetrics } = await import('./metrics-tap.js');
@@ -209,10 +200,8 @@ async function main() {
209
200
  }
210
201
  await server.connect(stdioHooks.argShapeFor || stdioHooks.strictArgs
211
202
  ? withArgNormalization(transport,
212
- // Gated exactly as the HTTP leg gates `argShapeFor`. Passing the
213
- // shape unconditionally made stdio rename keys while
214
- // GOOGLE_ARG_NORMALIZE=off, so the same call succeeded on stdio
215
- // and failed on HTTP once screening rejects.
203
+ // Gated exactly as the HTTP leg gates argShapeFor, or GOOGLE_ARG_NORMALIZE=off
204
+ // would rename keys on stdio while HTTP's screening rejects the same call.
216
205
  stdioHooks.argShapeFor ?? (() => undefined), undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined, stdioHooks.strictArgs)
217
206
  : transport);
218
207
  }
@@ -221,31 +210,30 @@ async function main() {
221
210
  const owners = parseOwnerEmails(process.env);
222
211
  const gates = mtBootGates();
223
212
  if (gates?.multiTenant) {
224
- // Same fail-fast SHAPE, different condition: an ungated HTTP endpoint
225
- // must never boot; under tenancy the gate is "a valid provisioning
213
+ // Same fail-fast shape as below, but the gate is "a valid provisioning
226
214
  // mechanism exists", not a flat owner allowlist.
227
215
  gates.assertProvisioningGate();
228
216
  }
229
217
  else {
230
- // BR3 / C13: the owner allowlist is the entire multi-tenant collapse;
218
+ // The owner allowlist is the entire multi-tenant collapse;
231
219
  // refuse to open an ungated HTTP endpoint.
232
220
  if (owners.length === 0) {
233
221
  process.stderr.write('E_OWNER_EMAILS_REQUIRED: MCP_TRANSPORT includes http but MCP_OWNER_EMAILS is empty. Set MCP_OWNER_EMAILS to the Google email(s) allowed to authenticate.\n');
234
222
  process.exit(1);
235
223
  }
236
224
  }
237
- // BR7: stateless HTTP cannot push tools/list_changed, so it forces curated.
225
+ // Stateless HTTP cannot push tools/list_changed, so it forces curated.
238
226
  const configuredMode = (process.env.GOOGLE_DISCOVERY ?? '').trim().toLowerCase();
239
227
  if (configuredMode && configuredMode !== 'curated') {
240
228
  process.stderr.write(`GOOGLE_DISCOVERY="${configuredMode}" is ignored over HTTP; the stateless transport forces "curated".\n`);
241
229
  }
242
- const httpServer = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
230
+ const httpServer = createMcpServer(pkg.version);
243
231
  const httpMetrics = initMetricsFor('http', 'curated');
244
232
  const httpCtx = buildIdentityContext(process.env, { transport: 'http' });
245
233
  const registry = buildRegistry(httpServer, httpCtx, 'curated', httpMetrics);
246
234
  registry.installListHandler();
247
235
  registerSetupPrompt(httpServer);
248
- // B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
236
+ // Mount the OAuth 2.1 AS (MCP-client tokens + owner login) + the Bearer authenticator.
249
237
  const { buildAuthServer, verifiedEmailFromIdToken } = await import('./oauth-as.js');
250
238
  const { jwtSecretFrom } = await import('./mcp-token.js');
251
239
  const { resolveJwtKey, resolveMasterKeyForDispatch } = await import('./master-key.js');
@@ -288,7 +276,7 @@ async function main() {
288
276
  aliasEmail: (alias) => getAccountSet().configs[alias]?.email,
289
277
  log: (l) => process.stderr.write(`[as] ${l}\n`),
290
278
  });
291
- // BV-1: over HTTP, a dead/missing per-account token surfaces a clickable,
279
+ // Over HTTP, a dead/missing per-account token surfaces a clickable,
292
280
  // signed re-auth link into the AS's alias_reauth flow instead of a CLI hint.
293
281
  const { setHttpReauthLink } = await import('./reauth-hint.js');
294
282
  setHttpReauthLink((alias) => authServer.reauthLink(alias));
@@ -314,7 +302,7 @@ async function main() {
314
302
  });
315
303
  await host.start();
316
304
  process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
317
- // Graceful shutdown so `docker run --init` (B16) forwards SIGTERM cleanly.
305
+ // Graceful shutdown so `docker run --init` forwards SIGTERM cleanly.
318
306
  const shutdown = () => {
319
307
  host.close().finally(() => process.exit(0));
320
308
  };
@@ -15,7 +15,7 @@ export interface KeyDeps {
15
15
  hasAnyToken?: () => boolean;
16
16
  }
17
17
  export declare function __setKeychainFactoryForTest(fn: ((account: string) => KeychainEntry | null) | null): void;
18
- /** BR-1 single choke point: no other module may read the env vars directly.
18
+ /** Single choke point: no other module may read the env vars directly.
19
19
  * Boot/CLI form: failures exit with a clean stderr slug ("fatal at startup"). */
20
20
  export declare function resolveMasterKey(deps?: KeyDeps): ResolvedKey;
21
21
  /** Dispatch form: identical resolution, but failures THROW (caught by the tool
@@ -28,14 +28,14 @@ export declare function peekMasterKeyProvenance(deps?: KeyDeps): KeyProvenance |
28
28
  * outstanding MCP tokens and clients re-auth silently. */
29
29
  export declare function resolveJwtKey(deps?: KeyDeps): ResolvedKey;
30
30
  /**
31
- * BR-5: after the FIRST successful token decrypt with an env-sourced key,
31
+ * After the FIRST successful token decrypt with an env-sourced key,
32
32
  * mirror it into the keychain (only when the keychain holds no entry) so the
33
33
  * key survives an env loss. Gating on a successful decrypt means a stale or
34
34
  * wrong env key is never mirrored. Best-effort: failures are doctor material,
35
35
  * never fatal.
36
36
  */
37
37
  export declare function noteSuccessfulDecrypt(): void;
38
- /** B6 reset: drop the generated MASTER_KEY material (0600 file + keychain
38
+ /** Reset: drop the generated MASTER_KEY material (0600 file + keychain
39
39
  * entry) so the next run re-provisions. Never removes an env-provided key (the
40
40
  * deployer owns that; `env:true` flags it so reset can warn). The caller MUST
41
41
  * have wiped every .enc token first — regenerating with tokens present would
@@ -5,10 +5,8 @@ import { createRequire } from 'node:module';
5
5
  import { configDir, failStartup } from './config-file.js';
6
6
  import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
7
7
  import { getTokenDir } from './accounts.js';
8
- // The keychain is a native optionalDependency: load lazily and synchronously
9
- // (createRequire — the token store's key read is sync), and treat EVERY
10
- // failure (no prebuild, native load error, headless Linux without a Secret
11
- // Service) as "keychain unavailable", falling through to the 0600 file.
8
+ // Native optionalDependency, loaded lazily and synchronously (the token
9
+ // store's key read is sync); EVERY failure falls through to the 0600 file.
12
10
  const require_ = createRequire(import.meta.url);
13
11
  function systemKeychain(account) {
14
12
  try {
@@ -32,8 +30,7 @@ function systemKeychain(account) {
32
30
  }
33
31
  }
34
32
  // Default keychain factory, replaceable ONLY for tests: unit tests must never
35
- // read or write the developer's real OS keyring (the BR-5 mirror once leaked a
36
- // test fixture key into a real GNOME keyring — never again).
33
+ // read or write the developer's real OS keyring.
37
34
  let keychainFactory = systemKeychain;
38
35
  export function __setKeychainFactoryForTest(fn) {
39
36
  keychainFactory = fn ?? systemKeychain;
@@ -46,14 +43,8 @@ function anyEncFileUnder(dir) {
46
43
  return false;
47
44
  }
48
45
  }
49
- // Glob the token dir instead of iterating registered aliases: an orphan .enc
50
- // (alias removed from the registry, or a partial GOOGLE_ACCOUNTS override in
51
- // one client) must still stop a fresh key from being minted. TOKEN_STORE_PATH
52
- // may relocate the token dir outside configDir(), so scan it directly; the
53
- // recursive configDir() walk then covers BOTH the tenant-namespaced token root
54
- // (tenants/<id>) AND any top-level master-key-encrypted registry beside it
55
- // (e.g. a downstream tenant map / invite store). Minting a fresh MASTER_KEY
56
- // over any existing .enc bricks it irreversibly, so the scan must see them all.
46
+ // Globs the token dir rather than iterating registered aliases, so an orphan
47
+ // .enc still stops a fresh key from being minted. See docs/internals.md.
57
48
  function anyTokenFileExists() {
58
49
  return anyEncFileUnder(getTokenDir()) || anyEncFileUnder(configDir());
59
50
  }
@@ -98,7 +89,7 @@ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
98
89
  const fromFile = readKeyFile(filePath);
99
90
  if (fromFile)
100
91
  return { key: fromFile, provenance: 'file' };
101
- // Generate-on-setup. The hard guard is cc-security Cgen: minting a fresh
92
+ // Generate-on-setup. The hard guard: minting a fresh
102
93
  // MASTER_KEY while <alias>.enc files exist would silently brick them all.
103
94
  if (hardGuard && hasAnyToken()) {
104
95
  fail('E_MASTER_KEY_MISSING_TOKENS_EXIST', `no ${envName} found in env, keychain or ${filePath}, but encrypted tokens exist under ${getTokenDir()}. ` +
@@ -115,10 +106,8 @@ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
115
106
  return { key: racedFile, provenance: 'file' };
116
107
  // Exactly randomBytes(32) base64: hits deriveKey's 32-byte fast path.
117
108
  const generated = randomBytes(32).toString('base64');
118
- // The 0600 file is ALWAYS written: on Linux the "keychain" can be the
119
- // kernel keyutils keyring, which does not survive a reboot — a key that
120
- // lives only there bricks every token at the next boot. The file is the
121
- // durable sink; byte-exact read-back gates any use of the key.
109
+ // The 0600 file is ALWAYS written (some keychain backends don't survive
110
+ // a reboot); byte-exact read-back gates any use of the key.
122
111
  atomicWriteFileSync(filePath, `${generated}\n`, 0o600);
123
112
  if (readKeyFile(filePath) !== generated) {
124
113
  fail('E_KEY_PROVISIONING_FAILED', `generated ${envName} could not be read back byte-exact from ${filePath}; refusing to encrypt anything with an unverified key.`);
@@ -151,7 +140,7 @@ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
151
140
  }
152
141
  let cachedMaster = null;
153
142
  let cachedJwt = null;
154
- /** BR-1 single choke point: no other module may read the env vars directly.
143
+ /** Single choke point: no other module may read the env vars directly.
155
144
  * Boot/CLI form: failures exit with a clean stderr slug ("fatal at startup"). */
156
145
  export function resolveMasterKey(deps) {
157
146
  if (deps)
@@ -189,7 +178,7 @@ export function resolveJwtKey(deps) {
189
178
  }
190
179
  let mirrored = false;
191
180
  /**
192
- * BR-5: after the FIRST successful token decrypt with an env-sourced key,
181
+ * After the FIRST successful token decrypt with an env-sourced key,
193
182
  * mirror it into the keychain (only when the keychain holds no entry) so the
194
183
  * key survives an env loss. Gating on a successful decrypt means a stale or
195
184
  * wrong env key is never mirrored. Best-effort: failures are doctor material,
@@ -211,7 +200,7 @@ export function noteSuccessfulDecrypt() {
211
200
  // Keychain backend unavailable; env stays the working source.
212
201
  }
213
202
  }
214
- /** B6 reset: drop the generated MASTER_KEY material (0600 file + keychain
203
+ /** Reset: drop the generated MASTER_KEY material (0600 file + keychain
215
204
  * entry) so the next run re-provisions. Never removes an env-provided key (the
216
205
  * deployer owns that; `env:true` flags it so reset can warn). The caller MUST
217
206
  * have wiped every .enc token first — regenerating with tokens present would
@@ -88,7 +88,7 @@ export declare class ReplayGuard {
88
88
  reserve(jti: string, ttlMs: number, nowMs: number): 'ok' | 'full';
89
89
  /** Record a jti as spent. A full guard refuses instead of evicting a live
90
90
  * jti: a flood of spends would otherwise make a spent artifact usable again,
91
- * such as a callback the browser binding refused (H4 F1). `artifactExpMs`
91
+ * such as a callback the browser binding refused. `artifactExpMs`
92
92
  * keeps the record alive as long as the artifact itself, even when the clock
93
93
  * stepped back between its signing and this spend. */
94
94
  spend(jti: string, ttlMs: number, nowMs: number, artifactExpMs?: number): ReplaySpend;
@@ -138,10 +138,10 @@ export interface RefreshStoreOptions {
138
138
  * Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
139
139
  * use, and a token carries its family and generation under a MAC, so
140
140
  * presenting any generation the family has rotated past is REUSE: the family
141
- * is revoked for the rest of its life (#7 / C14, H4 F2). The store keeps one
141
+ * is revoked for the rest of its life. The store keeps one
142
142
  * record per family, whatever its rotation count. Every read-modify-write goes
143
143
  * through a file lock so concurrent stdio+HTTP processes can't lost-update or
144
- * double-spend (#12). See docs/internals.md.
144
+ * double-spend. See docs/internals.md.
145
145
  */
146
146
  export declare class RefreshStore {
147
147
  private readonly path;
@@ -165,16 +165,6 @@ export declare class RefreshStore {
165
165
  private load;
166
166
  private save;
167
167
  private emit;
168
- /** Evict `sub`'s own least-recently-used sessions (both formats) so at
169
- * most `limit` of its live sessions remain; dropping one here is the same
170
- * as deleting it from `data.families` / `data.active` on a revoke, so it
171
- * can never be presented again (reuse detection for every OTHER family is
172
- * untouched). Only `sub`'s own entries are ever inspected.
173
- *
174
- * `keepKey` is the family just issued: it is excluded from the ranking so
175
- * a clock step back, or a concurrent rotate's `usedAt = max(usedAt, nowMs)`
176
- * racing ahead of it under the lock, can never make the brand-new family
177
- * look like the least recently used one and drop itself. */
178
168
  private enforceFamilyCap;
179
169
  private newFamilyId;
180
170
  issue(nowMs: number, sub: string): string;