mcp-google-multi 6.0.0-alpha.72 → 6.0.0-alpha.73

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 (73) hide show
  1. package/dist/accounts.js +12 -25
  2. package/dist/arg-normalize.js +15 -35
  3. package/dist/arg-strict.js +8 -28
  4. package/dist/auth.js +4 -9
  5. package/dist/boot-gates.js +2 -4
  6. package/dist/cimd-cache.js +5 -11
  7. package/dist/client-config.js +6 -12
  8. package/dist/client.js +0 -0
  9. package/dist/compose.js +4 -11
  10. package/dist/config-file.js +7 -12
  11. package/dist/discover.js +4 -9
  12. package/dist/discovery-client.js +14 -33
  13. package/dist/doctor.d.ts +1 -1
  14. package/dist/doctor.js +7 -12
  15. package/dist/env-load.js +2 -5
  16. package/dist/executor.js +2 -4
  17. package/dist/fanout.js +8 -17
  18. package/dist/fs-atomic.d.ts +0 -3
  19. package/dist/fs-atomic.js +6 -10
  20. package/dist/hint-provider.js +4 -10
  21. package/dist/http-config.js +2 -5
  22. package/dist/http-transport.js +35 -88
  23. package/dist/identity.js +4 -7
  24. package/dist/index.js +7 -18
  25. package/dist/master-key.js +7 -18
  26. package/dist/mcp-token.d.ts +37 -13
  27. package/dist/mcp-token.js +118 -41
  28. package/dist/metrics-cli.d.ts +1 -1
  29. package/dist/metrics-cli.js +1 -1
  30. package/dist/metrics-tap.js +2 -5
  31. package/dist/migrate-config.d.ts +1 -2
  32. package/dist/migrate-config.js +2 -4
  33. package/dist/net-tuning.js +2 -4
  34. package/dist/oauth-as.d.ts +6 -1
  35. package/dist/oauth-as.js +39 -72
  36. package/dist/oauth-consent.js +4 -10
  37. package/dist/outbound-allowlist.js +4 -9
  38. package/dist/reauth-hint.js +2 -5
  39. package/dist/registry-transfer.js +4 -12
  40. package/dist/registry.d.ts +0 -8
  41. package/dist/registry.js +32 -66
  42. package/dist/scope-catalog.js +4 -7
  43. package/dist/scope-observability.d.ts +1 -2
  44. package/dist/scope-observability.js +8 -14
  45. package/dist/services.d.ts +3 -0
  46. package/dist/services.js +5 -10
  47. package/dist/ssrf-guard.js +11 -21
  48. package/dist/tenant-purge.js +2 -4
  49. package/dist/token-store.js +0 -0
  50. package/dist/tools/_errors.js +21 -38
  51. package/dist/tools/_local-files.js +4 -8
  52. package/dist/tools/account-wizard.js +27 -48
  53. package/dist/tools/admin.js +2 -3
  54. package/dist/tools/analytics.js +4 -5
  55. package/dist/tools/calendar.js +4 -7
  56. package/dist/tools/chat.js +2 -3
  57. package/dist/tools/contacts.js +5 -6
  58. package/dist/tools/docs.js +4 -9
  59. package/dist/tools/drive.js +18 -27
  60. package/dist/tools/forms.js +2 -3
  61. package/dist/tools/gmail-mime.js +6 -11
  62. package/dist/tools/gmail.js +22 -36
  63. package/dist/tools/google-api.js +4 -6
  64. package/dist/tools/meet.js +2 -3
  65. package/dist/tools/searchconsole.js +2 -3
  66. package/dist/tools/sheets.js +2 -3
  67. package/dist/tools/slides.js +2 -3
  68. package/dist/tools/tasks.js +2 -3
  69. package/dist/trim.js +2 -3
  70. package/dist/usage-metrics.js +5 -12
  71. package/dist/write-control.d.ts +0 -3
  72. package/dist/write-control.js +16 -4
  73. package/package.json +1 -1
package/dist/doctor.js CHANGED
@@ -35,9 +35,8 @@ const DEFAULT_DEPS = {
35
35
  probeApi: (alias) => probeApiEnablement(alias),
36
36
  probeHttp: (cfg) => probeHttpEndpoints(cfg),
37
37
  };
38
- /** §7 live check: the advertised OAuth metadata must derive from MCP_PUBLIC_URL
39
- * exactly — one mismatch between PRM `resource` / AS `issuer` and what clients
40
- * compute from the public URL is the perpetual-401 interop bug. */
38
+ // §7 live check: a mismatch between PRM/AS metadata and what clients compute
39
+ // from the public URL is the perpetual-401 interop bug.
41
40
  async function probeHttpEndpoints(cfg) {
42
41
  try {
43
42
  const prmRes = await fetch(`${cfg.publicUrl}/.well-known/oauth-protected-resource`, {
@@ -150,7 +149,7 @@ function sectionConfig(deps, set, tenantScope = false) {
150
149
  return { id: 2, title: 'Config', verdict, lines, ...(hint ? { hint } : {}), ...(slug ? { slug } : {}) };
151
150
  }
152
151
  /** One line, state AND source, so the people being measured can see both
153
- * here and in `diagnose` (metrics spec section 2). Read-only. */
152
+ * here and in `diagnose`. Read-only. */
154
153
  export function usageMetricsStatusLine(env) {
155
154
  const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
156
155
  let configValue;
@@ -337,10 +336,8 @@ export function overallVerdict(sections) {
337
336
  return worst;
338
337
  }
339
338
  export async function runDiagnostics(deps = DEFAULT_DEPS, opts = {}) {
340
- // Tenant scope omits the OPERATOR sections: §3 (key provenance = box
341
- // infrastructure) and §7 (its owner-gate lines print MCP_OWNER_EMAILS
342
- // values — other people's addresses from a tenant's seat), and trims §2 to
343
- // the context's own account count.
339
+ // Tenant scope omits the OPERATOR sections (§3 key provenance, §7 owner-gate
340
+ // emails) and trims §2 to the context's own account count.
344
341
  const tenantScope = opts.scope === 'tenant';
345
342
  const sections = [];
346
343
  sections.push(sectionRuntime(deps, tenantScope));
@@ -380,10 +377,8 @@ export function renderDoctorText(report) {
380
377
  }
381
378
  const EMAIL_LOCAL_CHAR = /[A-Za-z0-9._%+-]/;
382
379
  const EMAIL_DOMAIN_CHAR = /[A-Za-z0-9.-]/;
383
- /** Mask the local-part of an email so a report never carries a full address.
384
- * A linear scan, not a regex: the `local* @domain` pattern backtracks
385
- * polynomially on long local-charset runs without an @ (CodeQL
386
- * js/polynomial-redos), and report text can embed arbitrary error strings. */
380
+ // A linear scan, not a regex: `local*@domain` backtracks polynomially on long
381
+ // local-charset runs without an @ (CodeQL js/polynomial-redos).
387
382
  function maskEmails(text) {
388
383
  let out = '';
389
384
  let i = 0;
package/dist/env-load.js CHANGED
@@ -3,11 +3,8 @@ import path from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
- // Precedence: real env > CWD .env > package-root .env > ~/.config .env.
7
- // process.loadEnvFile never overwrites keys already in process.env (pinned by
8
- // tests/env-load.test.ts T4), so highest-priority file loads FIRST and real
9
- // env wins by already being present; the boot snapshot re-assert is a second
10
- // line of defense should those semantics ever change.
6
+ // Precedence: real env > CWD .env > package-root .env > ~/.config .env;
7
+ // the boot snapshot re-assert below is a second line of defense.
11
8
  export function loadEnvFiles(paths = {}) {
12
9
  // loadEnvFile exists since 20.12, so a feature check alone would let 20.12-21.x through.
13
10
  const major = Number(process.versions.node.split('.', 1)[0]);
package/dist/executor.js CHANGED
@@ -91,10 +91,8 @@ export async function executeApiMethod(method, args, deps = {}) {
91
91
  });
92
92
  const text = JSON.stringify(res.data ?? null);
93
93
  if (text.length > MAX_RESPONSE_CHARS) {
94
- // Budget the ENCODED head: jsonResult re-stringifies it, so every quote
95
- // and backslash inside doubles. Slicing raw characters made the emitted
96
- // result exceed the cap this tool advertises, by up to 15 percent on a
97
- // quote-dense listing.
94
+ // Budget the ENCODED head: jsonResult re-stringifies it, so slicing raw
95
+ // characters overshot the advertised cap on a quote-dense listing.
98
96
  return jsonResult({
99
97
  truncated: true,
100
98
  totalChars: text.length,
package/dist/fanout.js CHANGED
@@ -4,19 +4,13 @@ import { echoName, echoNames, MAX_SUGGESTED_KEYS } from './arg-strict.js';
4
4
  import { hints } from './hint-provider.js';
5
5
  export const CSV_RE = /^[a-zA-Z0-9_-]+(\s*,\s*[a-zA-Z0-9_-]+)+$/;
6
6
  const FANOUT_CONCURRENCY = 5;
7
- // `aliases` is required (no global default): the field is baked into a
8
- // registry's inputSchema, so it must read THAT registry's account set or a
9
- // '*' would advertise and accept another registry's aliases. It is a getter:
10
- // the set is read at parse time, so an alias added mid-session (account_add,
11
- // a tenant link) is valid on tools registered before it existed.
7
+ // `aliases` is a getter read at parse time (not baked), so a registry only
8
+ // ever sees ITS OWN account set, and a mid-session alias (account_add) is valid on tools registered before it existed.
12
9
  export function fanoutAccountField(description, aliases) {
13
10
  const boot = aliases();
14
11
  const csvExample = boot.length > 1 ? `; or a CSV subset like "${boot.slice(0, 2).join(',')}"` : '';
15
- // The union's OWN error only fires when every branch aborts at the type
16
- // check (a non-string). For a string, zod surfaces the single non-aborted
17
- // branch verbatim, which is the CSV regex, so a mistyped alias used to read
18
- // as "Invalid string: must match pattern /^[a-zA-Z0-9_-]+(,...)/" and never
19
- // named an alias. Hence the same message on BOTH.
12
+ // Same message on both union branches: for a string, zod surfaces the
13
+ // single non-aborted branch (the CSV regex) verbatim, which never named the alias.
20
14
  const bad = () => unknownAliasMessage(aliases(), true);
21
15
  return z
22
16
  // The selector branch stays first: tools/list injects the live
@@ -46,9 +40,8 @@ export function noAccountsResult() {
46
40
  export function parseAccountSelector(value, accounts = getAccountSet().aliases) {
47
41
  if (value === '*')
48
42
  return { ok: true, fanout: true, aliases: [...accounts] };
49
- // A single alias used to skip validation entirely and reach getClient, whose
50
- // throw landed on the mapper's floor as an unclassified upstream_error. Same
51
- // check and same envelope wherever the alias was typed.
43
+ // A single alias used to skip validation and reach getClient, whose throw
44
+ // landed on the mapper's floor as an unclassified upstream_error.
52
45
  if (!value.includes(',')) {
53
46
  return accounts.includes(value)
54
47
  ? { ok: true, fanout: false, aliases: [value] }
@@ -132,10 +125,8 @@ export async function runFanout(handler, args, aliases) {
132
125
  await Promise.all(Array.from({ length: Math.min(FANOUT_CONCURRENCY, aliases.length) }, () => worker()));
133
126
  const failed = results.filter((r) => !r.ok).length;
134
127
  if (failed === results.length && results.length > 0) {
135
- // Every account failed, so this IS an error result, and used to carry
136
- // `isError` with no top-level error, hint or retriable: the agent saw a
137
- // successful-looking object flagged as an error, and metrics bucketed it
138
- // as `other`. Hoist a real envelope, keeping the per-account detail.
128
+ // Every account failed, so this IS an error result: hoist a real
129
+ // top-level envelope (error/hint/retriable), keeping the per-account detail.
139
130
  const slugs = [...new Set(results.map((r) => r.error?.error ?? 'internal'))];
140
131
  const hints = [...new Set(results.map((r) => r.error?.hint).filter(Boolean))];
141
132
  const retriable = results.every((r) => r.error?.retriable === true);
@@ -1,6 +1,3 @@
1
- /** Which process instance holds a lock: PID namespace id, start time in
2
- * clock ticks since boot, and a boot id prefix. A part this process cannot
3
- * read is `host` or `0` (all three without /proc). */
4
1
  interface LockIdentity {
5
2
  ns: string;
6
3
  start: string;
package/dist/fs-atomic.js CHANGED
@@ -129,9 +129,8 @@ function holderDead(holder, now) {
129
129
  // Two unreadable parts compare equal without naming the same namespace or
130
130
  // boot, so only a fully known identity is looked up by start time.
131
131
  if (SELF.ns !== 'host' && SELF.boot !== '0' && SELF.start !== '0' && owner.start !== '0') {
132
- // A live namespace id is never shared, so the recorded pid is ours to
133
- // look up: a restarted container reuses both the id and the pid, but not
134
- // the start time.
132
+ // A live namespace id is never shared, so the recorded pid is ours to look
133
+ // up: a restarted container reuses both the id and the pid, but not the start time.
135
134
  const start = startOf(pid);
136
135
  if (start !== undefined)
137
136
  return start !== owner.start;
@@ -141,9 +140,8 @@ function holderDead(holder, now) {
141
140
  return holder.mtimeMs < PROCESS_START_MS;
142
141
  return probeDead(pid) || leaseOver;
143
142
  }
144
- // One waiter at a time breaks a dead lock: two breakers could otherwise both
145
- // see the dead holder, and the second would unlink the first one's new lock.
146
- // False when another waiter is breaking it.
143
+ // One waiter at a time breaks a dead lock, or the second could unlink the
144
+ // first one's new lock; false when another waiter is already breaking it.
147
145
  function breakLock(lock, holder) {
148
146
  const breaker = `${lock}.break`;
149
147
  // A token, not the inode: a breaker created after ours was removed can
@@ -198,10 +196,8 @@ function releaseBreaker(breaker, token) {
198
196
  throw error;
199
197
  }
200
198
  }
201
- // Hardlink-based advisory lock, generalized path-keyed from the token store's
202
- // alias-keyed original. The lock body is the bare pid, as older readers expect;
203
- // the owner file stays linked while held and names the holder's process
204
- // instance. See docs/internals.md.
199
+ // Hardlink-based advisory lock; the lock body is the bare pid, as older
200
+ // readers expect. See docs/internals.md.
205
201
  export function withFileLock(absPath, fn, label = `lock: ${absPath}`) {
206
202
  const dir = path.dirname(absPath);
207
203
  const lock = path.join(dir, `.${path.basename(absPath)}.lock`);
@@ -1,9 +1,5 @@
1
- // A plain array literal, not derived from the union: deriving it would need a
2
- // value-level mirror of the type anyway, and this way a missing member is
3
- // caught by the completeness check below instead of silently compiling.
4
- // `as const` so that check narrows to the literal tuple rather than widening
5
- // every element back to the whole HintCase['kind'] union (which would make
6
- // it pass trivially, whatever was listed).
1
+ // A plain array literal, not derived from the union, so a missing member is
2
+ // caught by the completeness check below rather than silently compiling.
7
3
  export const HINT_KINDS = [
8
4
  'reauth',
9
5
  'legacy_plaintext_token',
@@ -43,10 +39,8 @@ let installed = null;
43
39
  export function setHintProvider(p) {
44
40
  installed = p;
45
41
  }
46
- // A throwing provider must never take down the call site: every call site
47
- // does `hints()?.render(...)?.hint ?? <default>`, so a render that cannot
48
- // produce a Rendered falls back to the same literal a missing provider
49
- // would, and the provider's own error text never reaches a client.
42
+ // A throwing provider must never take down the call site: it falls back to
43
+ // the same literal a missing provider would, never leaking its own error text.
50
44
  export function hints() {
51
45
  if (!installed)
52
46
  return null;
@@ -1,8 +1,5 @@
1
- // Transport selection + public-base-URL / Origin / host config plumbing.
2
- // Pure, resolved once at boot; consumed by the HTTP host and the AS metadata.
3
- // Owns the canonicalization rule: a single mismatch
4
- // between the advertised resource URI and the client's `resource`/`aud` = a
5
- // perpetual 401 loop (the #1 interop bug), so it is normalized exactly once.
1
+ // Transport + public-base-URL / Origin / host config, pure and resolved once
2
+ // at boot. A mismatch between the advertised URI and the client's `aud` is a perpetual 401 loop.
6
3
  export const DEFAULT_HTTP_HOST = '127.0.0.1';
7
4
  export const DEFAULT_HTTP_PORT = 4243;
8
5
  export const CLAUDE_AI_ORIGIN = 'https://claude.ai';
@@ -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 oauth-as.ts; the default is a loopback-owner
5
- // authenticator so the local-HTTP model works without the AS.
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: 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' &&
@@ -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;
@@ -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)
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 a
15
- // multi-tenant host 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',
package/dist/index.js CHANGED
@@ -139,10 +139,8 @@ async function main() {
139
139
  }
140
140
  const wantStdio = httpCfg.transport === 'stdio' || httpCfg.transport === 'both';
141
141
  const wantHttp = transportIncludesHttp(httpCfg.transport);
142
- // The SERVER never boots with an empty registry — stdio always, HTTP
143
- // when single-owner. The bootstrap and diagnostic CLIs above already
144
- // returned. A multi-tenant boot instead refuses the two process-wide env
145
- // 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.
146
144
  const mt = isMultiTenantBoot();
147
145
  if (accountsAssertRequired(wantStdio, mt))
148
146
  assertServerAccountsConfigured();
@@ -154,9 +152,7 @@ async function main() {
154
152
  // specced "fatal at startup", never mid-dispatch.
155
153
  const { resolveMasterKey } = await import('./master-key.js');
156
154
  resolveMasterKey();
157
- // Local usage metrics: fail-closed resolve, self-announcing source, null
158
- // when off (no wrapper, no dir, nothing initializes). One instance per
159
- // transport so `boots` keys stay honest under `both`.
155
+ // One Metrics instance per transport so `boots` keys stay honest under `both`.
160
156
  const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
161
157
  const metricsState = resolveUsageMetrics(process.env, loadConfigFile()?.usageMetrics, envSrc?.kind === 'file' ? envSrc.file : undefined);
162
158
  if (metricsState.warning)
@@ -196,11 +192,7 @@ async function main() {
196
192
  registry.installListHandler();
197
193
  registerSetupPrompt(server);
198
194
  const stdioHooks = requestHooksFor(registry, stdioCtx, stdioMetrics);
199
- // Outbound runs outermost-first, so the composition is deliberate: the
200
- // envelope rewrite is INNERMOST (last to touch the frame), the tap sits
201
- // above it (classifying the ORIGINAL validation prose), arg normalization
202
- // outermost. The tap only counts outbound JSON-RPC error frames (no
203
- // handler ran) plus id->tool names.
195
+ // Composition order (outbound runs outermost-first): see docs/internals.md (Argument normalization).
204
196
  let transport = withValidationEnvelope(new StdioServerTransport(), stdioHooks.validationEnvelope);
205
197
  if (stdioMetrics) {
206
198
  const { tapUsageMetrics } = await import('./metrics-tap.js');
@@ -208,10 +200,8 @@ async function main() {
208
200
  }
209
201
  await server.connect(stdioHooks.argShapeFor || stdioHooks.strictArgs
210
202
  ? withArgNormalization(transport,
211
- // Gated exactly as the HTTP leg gates `argShapeFor`. Passing the
212
- // shape unconditionally made stdio rename keys while
213
- // GOOGLE_ARG_NORMALIZE=off, so the same call succeeded on stdio
214
- // 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.
215
205
  stdioHooks.argShapeFor ?? (() => undefined), undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined, stdioHooks.strictArgs)
216
206
  : transport);
217
207
  }
@@ -220,8 +210,7 @@ async function main() {
220
210
  const owners = parseOwnerEmails(process.env);
221
211
  const gates = mtBootGates();
222
212
  if (gates?.multiTenant) {
223
- // Same fail-fast SHAPE, different condition: an ungated HTTP endpoint
224
- // 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
225
214
  // mechanism exists", not a flat owner allowlist.
226
215
  gates.assertProvisioningGate();
227
216
  }
@@ -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 env->keychain 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
  }
@@ -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.`);