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/accounts.js CHANGED
@@ -75,8 +75,7 @@ function noAccountsMessage() {
75
75
  */
76
76
  let warnedLegacy = false;
77
77
  export function resolveAccounts(env = process.env, filePath = configFilePath(), onInvalid = 'exit',
78
- // emptyOk: zero accounts is a valid set, not an error, even with 'throw'
79
- // (a context that has linked nothing yet). An invalid file still fails.
78
+ // emptyOk: zero accounts is a valid set, not an error, even with 'throw'.
80
79
  opts = {}) {
81
80
  const dir = opts.tokenDir ?? tokenDir;
82
81
  const adminEnv = parseCsv(env.GOOGLE_ADMIN_ACCOUNTS);
@@ -86,8 +85,7 @@ opts = {}) {
86
85
  throw new Error(`${slug}: ${message}`);
87
86
  failStartup(slug, message);
88
87
  };
89
- // Validate the legacy global override AT BOOT, whatever GOOGLE_TOOLSETS
90
- // selects — otherwise a typo only surfaces at dispatch time inside
88
+ // Validate at BOOT, or a typo only surfaces at dispatch time inside
91
89
  // account_list instead of the promised startup error.
92
90
  const legacyNames = parseCsv(env.GOOGLE_OPTIONAL_SCOPES);
93
91
  if (legacyNames.length > 0) {
@@ -120,19 +118,13 @@ opts = {}) {
120
118
  ...def,
121
119
  };
122
120
  }
123
- // Stat BEFORE read: a cross-process write landing between the two makes the
124
- // stamp conservative (flags stale again next dispatch) instead of pinning
125
- // stale content behind a fresh stamp.
121
+ // Stat BEFORE read, so a race lands on the conservative (stale) side.
126
122
  const preStamp = fileStamp(filePath, CONFIG_VERSION);
127
123
  const config = loadConfigFile(filePath, onInvalid);
128
124
  const entries = Object.entries(config?.accounts ?? {});
129
125
  if (entries.length === 0) {
130
- // Gap #23: an empty registry is NOT fatal at resolve time — the bootstrap and
131
- // diagnostic CLIs (doctor/reset/account import/migrate-config/config check)
132
- // must run on a fresh install. Only the stdio/http SERVER refuses to boot
133
- // empty, enforced by assertServerAccountsConfigured() in index.ts.
134
- // The dispatch-path reload ('throw') still throws so a mid-session emptied
135
- // config.json keeps the last-good registry instead of dropping tools.
126
+ // Not fatal here: bootstrap/diagnostic CLIs must run on a fresh install;
127
+ // only the server refuses to boot empty (index.ts).
136
128
  if (onInvalid === 'throw' && !opts.emptyOk) {
137
129
  throw new Error(`E_NO_ACCOUNTS_CONFIGURED: ${noAccountsMessage()}`);
138
130
  }
@@ -144,9 +136,8 @@ opts = {}) {
144
136
  stamp: `${config?.version ?? CONFIG_VERSION}:${preStamp.split(':')[1]}`,
145
137
  };
146
138
  }
147
- // Unknown bundle names fail loudly (a mis-scoped token is worse than a
148
- // clear error); v5 silently filtered them. Null prototype: profile names are
149
- // user input and must never collide with Object.prototype members.
139
+ // Unknown bundle names fail loudly (a mis-scoped token is worse than a clear
140
+ // error). Null prototype: profile names are user input, must never collide with Object.prototype.
150
141
  const scopeProfiles = Object.create(null);
151
142
  scopeProfiles.base = { bundles: [] };
152
143
  for (const [name, profile] of Object.entries(config?.scopeProfiles ?? {})) {
@@ -187,9 +178,8 @@ opts = {}) {
187
178
  ...def,
188
179
  };
189
180
  }
190
- /** env GOOGLE_DEFAULT_ACCOUNT > config.defaultAccount > unset. A configured
191
- * default naming an unknown alias refuses to start (E_DEFAULT_ACCOUNT_UNKNOWN —
192
- * deliberately NOT E_CONFIG_INVALID: the config is schema-valid). */
181
+ // env GOOGLE_DEFAULT_ACCOUNT > config.defaultAccount > unset; an unknown alias
182
+ // refuses to start as E_DEFAULT_ACCOUNT_UNKNOWN, not E_CONFIG_INVALID (the config is schema-valid).
193
183
  function resolveDefaultAccount(env, fromConfig, aliases, fail) {
194
184
  const fromEnv = env.GOOGLE_DEFAULT_ACCOUNT?.trim();
195
185
  const value = fromEnv || fromConfig || undefined;
@@ -208,17 +198,14 @@ export function fileStamp(filePath, version) {
208
198
  return `${version}:0`;
209
199
  }
210
200
  }
211
- // First-run shim: env is set and no config.json exists yet — materialize
212
- // the file so the wizard has something to edit. Env still wins this session;
213
- // a write failure must never block boot (warn on stderr and continue).
201
+ // env is set and no config.json exists yet: materialize the file so the
202
+ // wizard has something to edit; a write failure must never block boot.
214
203
  function materializeFirstRun(aliases, configs, filePath) {
215
204
  if (fs.existsSync(filePath))
216
205
  return;
217
206
  try {
218
207
  let wrote = false;
219
- // mutateConfigFile = lock + re-check + atomic write, so a concurrent
220
- // wizard/migrate writer is never clobbered (the loaded `current` is
221
- // re-read under the lock; only a still-absent file gets the env content).
208
+ // lock + re-check + atomic write, so a concurrent wizard/migrate writer is never clobbered.
222
209
  mutateConfigFile((current) => {
223
210
  if (Object.keys(current.accounts ?? {}).length > 0)
224
211
  return current;
@@ -1,10 +1,7 @@
1
1
  import { echoName, echoNames, screenArguments, unknownArgEnvelope, } from './arg-strict.js';
2
2
  import { logSafe, sliceClean } from './trim.js';
3
- // The SDK synthesizes input-validation failures as isError RESULTS, skipping
4
- // the handler entirely: no handler of ours runs, so no envelope exists and the
5
- // client gets free text. The text is prose in both SDK eras (v1 prefixed it
6
- // with "MCP error -32602: ", v2 dropped that prefix), while handler envelopes
7
- // are always JSON starting with "{".
3
+ // The SDK synthesizes input-validation failures as isError RESULTS (no handler
4
+ // runs), so the client gets free prose; handler envelopes always start with "{".
8
5
  const SCHEMA_VALIDATION_TEXT = /^(MCP error -32602: )?Input validation error:/;
9
6
  export function isSchemaValidationText(text) {
10
7
  return SCHEMA_VALIDATION_TEXT.test(text);
@@ -12,18 +9,11 @@ export function isSchemaValidationText(text) {
12
9
  /** Capture form of the v2 text. The tool-name class excludes ':' and ' ', so
13
10
  * the first ": " after the name is unambiguously the delimiter. */
14
11
  const VALIDATION_PROSE = /^(?:MCP error -32602: )?Input validation error: Invalid arguments for tool ([A-Za-z0-9_.-]+): ([\s\S]+)$/;
15
- /** The SDK's OTHER synthesized prose on the same catch. Both prefixes are
16
- * SDK-authored; a HANDLER's free text is not, and must survive untouched
17
- * (the account wizard answers in prose today). */
12
+ /** The SDK's OTHER synthesized prose on the same catch; a HANDLER's free text
13
+ * is not SDK-authored and must survive untouched. */
18
14
  const SDK_OUTPUT_PROSE = /^(?:MCP error -32602: )?Output validation error: /;
19
- // Wire-level tools/call argument normalization. Clients (LLMs) recurringly
20
- // snake_case a camelCase parameter (thread_id for threadId) and burn a retry
21
- // on the -32602. A schema-level fix is off the table: the SDK advertises an
22
- // EMPTY input schema for any non-object wrapper (pipe/preprocess), so the
23
- // only seam that keeps tools/list intact is the JSON-RPC message itself —
24
- // which is versioned MCP spec, stabler than any SDK internal. The rename is
25
- // provably lossless: it fires only when the sent key is NOT in the tool's
26
- // schema, its camelCase twin IS, and that twin was not also sent.
15
+ // Wire-level, not schema-level: the SDK advertises an EMPTY input schema for
16
+ // any non-object wrapper, so the JSON-RPC message is the only stable seam. See docs/internals.md.
27
17
  export function argNormalizationEnabled(env = process.env) {
28
18
  return !/^(0|false|off|no)$/i.test((env.GOOGLE_ARG_NORMALIZE ?? '').trim());
29
19
  }
@@ -94,11 +84,8 @@ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}
94
84
  return { action: 'forward', msg };
95
85
  const tool = m.params.name;
96
86
  const declared = opts.declaredFor(tool);
97
- // Unregistered tool. The spec keeps this a PROTOCOL error (unknown tool is
98
- // not something the model can fix by adjusting arguments), so the channel
99
- // stays a JSON-RPC error; only the message gets the did-you-mean and the
100
- // gated-service next step. Without `unknownTool`, or on a notification,
101
- // the SDK's own "not found" still answers.
87
+ // Unregistered tool stays a PROTOCOL error (not an arguments problem); only
88
+ // the message gets the did-you-mean. No `unknownTool`, or a notification: the SDK's own answers.
102
89
  if (!declared) {
103
90
  if (!opts.unknownTool || m.id === undefined)
104
91
  return { action: 'forward', msg };
@@ -115,8 +102,7 @@ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}
115
102
  if (screened.unknown.length === 0 && screened.redundant.length === 0)
116
103
  return { action: 'forward', msg };
117
104
  const all = [...screened.unknown.map((u) => u.sent), ...screened.redundant];
118
- // Key names only; argument VALUES never reach the log. The names are the
119
- // caller's, so they are escaped here and only here: the envelope is JSON.
105
+ // Key names only, escaped here and only here: the caller's names, never values, reach the log.
120
106
  log(`[args] ${tool}: undeclared ${logSafe(echoNames(all), 600)}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
121
107
  try {
122
108
  // Only ever a DECLARED key or the literal placeholder, so the metrics
@@ -167,13 +153,10 @@ export function withArgNormalization(transport, shapeFor, log, onRename, strict)
167
153
  if (outcome.action === 'forward')
168
154
  return handler(outcome.msg, extra);
169
155
  // Last-resort fallback: dispatch as before rather than hang the
170
- // caller. The stderr line and the counter already fired above.
171
- // `send` is async, so a rejected promise needs catching too: a
172
- // bare `void` left the client waiting for a frame that never came.
156
+ // caller; send is async, so a bare `void` would leave it waiting forever on rejection.
173
157
  const fallback = () => handler(normalized, extra);
174
158
  try {
175
- // Answer on the INNER transport so the metrics tap still sees the
176
- // frame and clears its pending id.
159
+ // On the INNER transport so the metrics tap still sees the frame and clears its pending id.
177
160
  void Promise.resolve(transport.send(outcome.response)).catch(fallback);
178
161
  }
179
162
  catch {
@@ -195,10 +178,8 @@ export function withArgNormalization(transport, shapeFor, log, onRename, strict)
195
178
  if (transport.setProtocolVersion) {
196
179
  wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
197
180
  }
198
- // v2-only, called by Protocol.connect() before start(). A no-op today (both
199
- // sides default to the same exported constant), but a proxy that silently
200
- // eats a member the SDK calls is a bug waiting for the first caller that
201
- // passes supportedProtocolVersions explicitly.
181
+ // A no-op today, but a proxy silently eating an SDK-called member is a bug
182
+ // waiting for the first caller that passes this explicitly.
202
183
  if (transport.setSupportedProtocolVersions) {
203
184
  wrapper.setSupportedProtocolVersions = (v) => transport.setSupportedProtocolVersions(v);
204
185
  }
@@ -230,9 +211,8 @@ export function outputFailureEnvelope(tool, text, account) {
230
211
  }
231
212
  export function rewriteValidationFrame(message, pending, opts) {
232
213
  const m = message;
233
- // Responses only. A server-initiated REQUEST (elicitation, sampling) also
234
- // carries an id, from a different id space, and evicting on it would drop a
235
- // real pending call.
214
+ // Responses only: a server-initiated REQUEST also carries an id (a
215
+ // different id space), and evicting on it would drop a real pending call.
236
216
  if (m.id === undefined || m.method !== undefined)
237
217
  return message;
238
218
  const call = pending.get(m.id);
@@ -1,14 +1,5 @@
1
- // Unknown-argument screening (backlog item 14). zod strips undeclared keys
2
- // before a handler runs, so a misremembered argument name used to produce a
3
- // SUCCESS response with wrong behavior: `parentId` on drive_create_folder was
4
- // dropped and the folder landed in My Drive root. Field-reported, then
5
- // reproduced: the typo, the correct name and a wholly invented key all
6
- // returned byte-identical responses.
7
- //
8
- // This module is pure and holds no registry reference. It never rewrites a key
9
- // and never touches a VALUE: it only decides that a key is undeclared, and
10
- // suggests what the caller probably meant. Rewriting would risk sending a real
11
- // value to Google on a guess, which is strictly worse than refusing.
1
+ // Unknown-argument screening: zod strips undeclared keys before a handler
2
+ // runs, so a misremembered name used to produce a SUCCESS with wrong behavior. See docs/internals.md.
12
3
  import { editDistance, SUGGEST_MAX_INPUT } from './scope-catalog.js';
13
4
  import { sliceClean } from './trim.js';
14
5
  /**
@@ -26,24 +17,15 @@ export function unknownArgMode(env = process.env) {
26
17
  process.stderr.write(`GOOGLE_ARG_UNKNOWN="${raw}" is not valid (reject | warn | off); using warn\n`);
27
18
  return 'warn';
28
19
  }
29
- // The staged rollout is over: `warn` shipped first as pure observability, and
30
- // this is the flip it was staging for. An undeclared argument is dropped by
31
- // zod before the handler runs, so `warn` means the CLIENT sees a confident
32
- // success for a call the server did not perform: drive_list with a misspelled
33
- // folder key returned the My Drive root, byte-identical to no argument at all.
34
- // A wrong answer that reads as right is the failure mode agents recover from
35
- // worst, so the default refuses the call and says what it probably meant.
20
+ // `warn` means the client sees a confident success for a call the server
21
+ // didn't perform; a wrong answer that reads as right is the worst failure mode to recover from.
36
22
  const DEFAULT_MODE = 'reject';
37
23
  /** Tools that legitimately accept open-ended top-level keys. Empty at 6.0.0:
38
24
  * the escape hatch is NOT one, because its open-endedness lives in the VALUES
39
25
  * of queryParams/body, never in its six fixed top-level keys. */
40
26
  export const STRICT_EXEMPT_TOOLS = new Set();
41
- /** Keys a CLIENT adds on its own, not keys the model chose. `random_string`
42
- * is the long-standing probe some clients send to a tool they read as taking
43
- * no arguments; the others are call-plumbing that bridges and proxies have
44
- * been observed to fold into `arguments` instead of `params._meta`, where the
45
- * spec puts them. Rejecting these would fail a call the model got right.
46
- * Measured against the full tool surface: none is a declared key. */
27
+ // Keys a CLIENT adds on its own, not keys the model chose (a probe some
28
+ // clients send to argument-less tools, plus call-plumbing bridges fold into `arguments`).
47
29
  const CLIENT_ARTIFACT_KEYS = new Set([
48
30
  'random_string',
49
31
  'toolCallId',
@@ -138,10 +120,8 @@ export function screenArguments(tool, args, declared) {
138
120
  if (STRICT_EXEMPT_TOOLS.has(tool) || declared.length === 0)
139
121
  return out;
140
122
  const declaredSet = new Set(declared);
141
- // Tier 0 (same key modulo case and separators) is a map lookup, so every key
142
- // gets it and a redundant twin is recognised past the ranking budget too.
143
- // A near-miss duplicate (a typo sent next to its declared key) is recognised
144
- // only within the budget: a call needs 9 of them before one is refused.
123
+ // Tier 0 is a map lookup, so a redundant twin is recognised past the
124
+ // ranking budget too; a near-miss duplicate still needs that budget.
145
125
  const byFlat = new Map();
146
126
  for (const k of declared)
147
127
  byFlat.set(flatten(k), [...(byFlat.get(flatten(k)) ?? []), k]);
package/dist/auth.js CHANGED
@@ -31,12 +31,8 @@ function parseCsvEnv(name) {
31
31
  .map(s => s.trim())
32
32
  .filter(Boolean);
33
33
  }
34
- /**
35
- * Legacy global override: a set GOOGLE_OPTIONAL_SCOPES acts as an
36
- * implicit "legacy-global" profile applied to every account (env wins over
37
- * file profiles). Unknown names now fail loudly where v5
38
- * silently dropped them — the intended migration signal.
39
- */
34
+ // A set GOOGLE_OPTIONAL_SCOPES acts as an implicit profile applied to every
35
+ // account (env wins over file profiles); unknown names fail loudly.
40
36
  function legacyGlobalProfile() {
41
37
  const names = parseCsvEnv('GOOGLE_OPTIONAL_SCOPES');
42
38
  if (names.length === 0)
@@ -130,9 +126,8 @@ export async function runAuthFlow(args) {
130
126
  // Auto-provisions on a fresh install (env > keychain > file > generate);
131
127
  // resolves eagerly so a provisioning failure surfaces before the browser opens.
132
128
  resolveMasterKey();
133
- // Shared ephemeral-port loopback flow (oauth-consent.ts): listen first, then
134
- // build the auth URL from the assigned redirect. CLI gets a patient timeout;
135
- // errors propagate to main()'s fatal handler like every other CLI failure.
129
+ // Listen first, then build the auth URL from the assigned redirect; errors
130
+ // propagate to main()'s fatal handler like every other CLI failure.
136
131
  const loop = await openLoopbackConsent({ timeoutMs: 10 * 60_000 });
137
132
  const oauth2Client = buildConsentClient(loop.redirect);
138
133
  // CSRF protection for the OAuth callback (RFC 6749 §10.12).
@@ -1,7 +1,5 @@
1
- // Multi-tenant boot gates. The free core never sets these — every
2
- // helper reduces to today's single-owner behavior when no gates are
3
- // installed. Deliberately a side-effect-free module (accounts.ts/auth.ts
4
- // resolve env at import time) so the tenancy module can import it cheaply.
1
+ // Multi-tenant boot gates: every helper reduces to single-owner behavior when
2
+ // no gates are installed. Deliberately side-effect-free, unlike accounts.ts/auth.ts.
5
3
  import { failStartup } from './config-file.js';
6
4
  let gates = null;
7
5
  export function setMtBootGates(g) {
@@ -1,9 +1,5 @@
1
- // GET /authorize is unauthenticated, and a client_id on an allowlisted
2
- // issuer with a path never seen before forces an outbound fetch. So the
3
- // fetches running at once are capped, one client_id is fetched once however
4
- // many requests wait on it, and only a document that passed validation is
5
- // kept. Errors are never cached (CIMD section 5.2), and a negative cache would
6
- // not help anyway: the attack varies the path, so its key never repeats.
1
+ // Bounds the unauthenticated-fetch surface of GET /authorize: capped
2
+ // in-flight fetches, one fetch per client_id, no error caching. See docs/internals.md.
7
3
  import { CimdHttpError, isTransientFetchError, SsrfBlockedError } from './ssrf-guard.js';
8
4
  import { logSafe } from './trim.js';
9
5
  export const CIMD_TTL_MS = 5 * 60_000;
@@ -69,16 +65,14 @@ export class CimdClientCache {
69
65
  }
70
66
  catch (e) {
71
67
  out = { error: e instanceof SsrfBlockedError ? 'E_CIMD_SSRF_BLOCKED' : 'E_CIMD_INVALID' };
72
- // A document that went bad is never served stale. An issuer that is down
73
- // or overloaded says nothing about the document, so the last good one
74
- // stays and answers this fetch's requests too (stale-if-error; the error
75
- // itself is never stored).
68
+ // stale-if-error: an issuer that's down/overloaded says nothing about
69
+ // the document, so the last good one stays and serves this fetch too.
76
70
  const stale = this.entries.get(clientId);
77
71
  if (!saysNothingAboutDocument(e))
78
72
  this.entries.delete(clientId);
79
73
  else if (stale && this.deps.now() - stale.at < CIMD_MAX_STALE_MS)
80
74
  out = { doc: stale.doc, fresh: false };
81
- // #13: the detail goes to the log only; the caller gets a generic answer.
75
+ // The detail goes to the log only; the caller gets a generic answer.
82
76
  this.deps.log(`CIMD fetch failed for ${logSafe(clientId, 128)}: ${logSafe(e instanceof Error ? e.message : 'non-Error rejection', 200)}`);
83
77
  }
84
78
  finally {
@@ -1,9 +1,5 @@
1
- // After the setup wizard finalizes an account, offer to register the
2
- // server with the user's MCP client so they never hand-edit JSON. Pure core
3
- // (detection + entry building + idempotent apply), deps-injectable for tests.
4
- // Consent + non-TTY behavior live in the CLI/tool surfaces; secrets are NEVER
5
- // inlined — the server self-loads its .env from ~/.config/mcp-google-multi, so
6
- // a stdio entry needs no secret env at all.
1
+ // Registers the server with the user's MCP client so they never hand-edit
2
+ // JSON. Pure core (detection + entry building + idempotent apply), deps-injectable for tests.
7
3
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
8
4
  import * as readline from 'node:readline';
9
5
  import path from 'node:path';
@@ -20,9 +16,8 @@ function resolve(deps) {
20
16
  mkdirp: deps.mkdirp ?? ((dir) => mkdirSync(dir, { recursive: true })),
21
17
  };
22
18
  }
23
- // Build paths for the TARGET platform's separators, not the running host's, so
24
- // the injected `platform` is honored deterministically (a Windows CI runner
25
- // must still produce posix paths for a linux target, and vice versa).
19
+ // Build paths for the TARGET platform's separators, not the running host's,
20
+ // so the injected `platform` is honored deterministically.
26
21
  function pp(r) {
27
22
  return r.platform === 'win32' ? path.win32 : path.posix;
28
23
  }
@@ -155,9 +150,8 @@ export function applyFileEntry(client, name, entry, deps = {}) {
155
150
  const had = Object.prototype.hasOwnProperty.call(prevServers, name);
156
151
  const next = { ...existing, mcpServers: { ...prevServers, [name]: entry } };
157
152
  r.mkdirp(pp(r).dirname(p));
158
- // Back up the prior file before rewriting so a bad write is recoverable
159
- // (these foreign configs are small and not concurrently written by us, so a
160
- // backup + direct write is sufficient — our own config.json uses fs-atomic).
153
+ // Back up before rewriting so a bad write is recoverable; our own
154
+ // config.json uses fs-atomic, but these small foreign configs don't need it.
161
155
  let backup;
162
156
  if (priorRaw !== null) {
163
157
  backup = `${p}.bak`;
package/dist/client.js CHANGED
Binary file
package/dist/compose.js CHANGED
@@ -1,8 +1,5 @@
1
- // Registry composition, extracted from index.ts so the tenancy module
2
- // (and tests) can build registries without touching the CLI entrypoint. This
3
- // module must stay free of import-time side effects of its own: no argv
4
- // parsing, no process.exit, no prints (accounts.js module-load resolution is
5
- // the one inherited, pre-existing exception on the import chain).
1
+ // Registry composition, extracted from index.ts so tests can build registries
2
+ // without touching the CLI entrypoint. No import-time side effects of its own.
6
3
  import { McpServer } from "@modelcontextprotocol/server";
7
4
  import { GENERATED_SERVICES } from './tools/generated/index.js';
8
5
  import { GENERATED_GATES, SERVICES, servicesAwaitingRestart, unknownToolMessage } from './services.js';
@@ -25,13 +22,9 @@ export function createMcpServer(version, opts = {}) {
25
22
  }
26
23
  export function buildRegistry(server, ctx, mode, metrics = null) {
27
24
  const policy = ctx.policy;
28
- // The registry's account view IS the context's: fan-out expansion,
29
- // selector validation and default-account injection all follow this
30
- // context, not the process global (identical for the free core's one ctx).
25
+ // The registry's account view IS the context's, not the process global.
31
26
  const registry = new ToolRegistry(server, policy, mode, metrics, () => ctx.accounts);
32
- // Every handler resolves its client, token reads and scope hints through
33
- // this context; ctx.accounts is read per call, never snapshotted here, so
34
- // the owner's live getter stays live.
27
+ // ctx.accounts is read per call, never snapshotted here, so a live getter stays live.
35
28
  const owner = isOwnerContext(ctx);
36
29
  const clientDeps = { getClientFn: ctx.getClient, localFiles: owner };
37
30
  const execDeps = {
@@ -13,9 +13,8 @@ export function configFilePath(env = process.env) {
13
13
  export function tenantsDir(env = process.env) {
14
14
  return path.join(configDir(env), 'tenants');
15
15
  }
16
- // A tenant id becomes a filesystem path segment exactly like an account
17
- // alias, so it rides the same traversal guard. 'owner' is the single-owner
18
- // context's subject and client lane, so no tenant may take it.
16
+ // A tenant id becomes a filesystem path segment like an account alias (same
17
+ // traversal guard); 'owner' is the single-owner subject, so no tenant may take it.
19
18
  function assertTenantId(tenantId) {
20
19
  if (!ALIAS_RE.test(tenantId) || isReservedAlias(tenantId) || tenantId === 'owner') {
21
20
  throw new Error(`E_TENANT_ID_INVALID: invalid tenant id "${tenantId}".`);
@@ -52,9 +51,7 @@ const accountEntrySchema = z.strictObject({
52
51
  admin: z.boolean().optional(),
53
52
  });
54
53
  // .strict() everywhere: any unknown or secret-shaped key (clientSecret,
55
- // masterKey, jwtKey, clientId) fails validation, structurally enforcing
56
- // secrets-env-only. scopeProfiles/defaultAccount/discovery/toolsets are part
57
- // of the frozen envelope; their consumers land in later slices.
54
+ // masterKey, jwtKey, clientId) fails validation, structurally enforcing secrets-env-only.
58
55
  export const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
59
56
  export function isReservedAlias(name) {
60
57
  return RESERVED_ALIASES.includes(name);
@@ -72,9 +69,8 @@ const configSchema = z.strictObject({
72
69
  defaultAccount: z.string().optional(),
73
70
  discovery: z.enum(['lazy', 'curated', 'eager']).optional(),
74
71
  toolsets: z.string().optional(),
75
- // Local usage metrics (metrics-feature-spec): absent = off. Downgrade rule:
76
- // a pre-6.0 build reading a config carrying this key fails E_CONFIG_INVALID
77
- // (strictObject); remove the key first.
72
+ // Absent = off. A pre-6.0 build reading a config carrying this key fails
73
+ // E_CONFIG_INVALID (strictObject); remove the key first to downgrade.
78
74
  usageMetrics: z.boolean().optional(),
79
75
  });
80
76
  export function failStartup(slug, message) {
@@ -125,9 +121,8 @@ export function loadConfigFile(filePath = configFilePath(), onInvalid = 'exit')
125
121
  const issue = result.error.issues[0];
126
122
  fail('E_CONFIG_INVALID', `${filePath}: ${issue.path.join('.') || '(root)'}: ${issue.message}. Secrets never belong in config.json.`);
127
123
  }
128
- // Post-schema because zod's key regex passes these; a __proto__ record key
129
- // is a prototype-pollution foothold, and Object.prototype member names break
130
- // plain-object lookups downstream.
124
+ // Post-schema: zod's key regex passes these, but a __proto__ record key is
125
+ // a prototype-pollution foothold downstream.
131
126
  const data = result.data;
132
127
  for (const [section, keys] of [
133
128
  ['accounts', Object.keys(data.accounts ?? {})],
package/dist/discover.js CHANGED
@@ -1,9 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { describePolicy } from './write-control.js';
3
3
  import { hints } from './hint-provider.js';
4
- // The description budget is the whole point of lazy mode: a full op list per
5
- // service put lazy tools/list at ~11.5k tokens, so descriptions carry a
6
- // CAPPED vocabulary and the complete catalog stays in this tool's RESULT.
4
+ // The description budget is the whole point of lazy mode: descriptions carry
5
+ // a CAPPED vocabulary, and the complete catalog stays in this tool's RESULT.
7
6
  const CURATED_OPS_CAP = 10;
8
7
  const GENERATED_GROUPS_CAP = 6;
9
8
  function opVocabulary(registry, service) {
@@ -15,8 +14,7 @@ function opVocabulary(registry, service) {
15
14
  parts.push(`${shown.join(', ')}${more > 0 ? ` +${more} more` : ''}`);
16
15
  }
17
16
  if (generated.length > 0) {
18
- // The generated long tail compresses to its resource groups: denser and
19
- // more selective than any truncated name list.
17
+ // Compresses to resource groups: denser than any truncated name list.
20
18
  const counts = new Map();
21
19
  for (const op of generated) {
22
20
  const group = op.split('_')[0];
@@ -31,10 +29,7 @@ function opVocabulary(registry, service) {
31
29
  }
32
30
  export function registerDiscoverTools(registry, policy) {
33
31
  const registerMeta = registry.registerMeta;
34
- // Agent-controllable runtime expansion: reveal the whole
35
- // curated quality layer for heavy Google work, collapse to reclaim the
36
- // name/schema context budget after. stdio-only semantics — over stateless
37
- // HTTP the mode is forced curated and these are no-ops.
32
+ // stdio-only semantics: over stateless HTTP the mode is forced curated and these are no-ops.
38
33
  registerMeta('discover_all', {
39
34
  description: 'Reveal ALL curated Google tools at once (instead of per-service discovery). Use when ' +
40
35
  'starting substantial Google work; prefer these over google_api_call. Pair with discover_reset.',
@@ -35,10 +35,8 @@ export const SUPPORTED_APIS = {
35
35
  vault: { id: 'vault', version: 'v1' },
36
36
  workspaceevents: { id: 'workspaceevents', version: 'v1' },
37
37
  };
38
- // Names agents actually type for an API, mapped to real SUPPORTED_APIS keys.
39
- // Motivated by observed escape-hatch misses ("analytics" is two Discovery
40
- // APIs, the Admin SDK is three); keep entries plural-target only when the
41
- // split is real.
38
+ // Names agents actually type for an API, mapped to real SUPPORTED_APIS keys;
39
+ // plural-target entries only where the split ("analytics" is two APIs) is real.
42
40
  export const API_ALIASES = {
43
41
  analytics: ['analyticsadmin', 'analyticsdata'],
44
42
  ga4: ['analyticsadmin', 'analyticsdata'],
@@ -74,9 +72,8 @@ export function discoveryCacheDir(env = process.env) {
74
72
  return (env.DISCOVERY_CACHE_PATH ??
75
73
  path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'mcp-google-multi', 'discovery'));
76
74
  }
77
- // Default cache path only (an operator's DISCOVERY_CACHE_PATH may be shared,
78
- // even /tmp, and keeps its mode), only when this process owns it, and best
79
- // effort. Clears group/other bits and keeps setuid/setgid/sticky.
75
+ // Default cache path only (an operator's DISCOVERY_CACHE_PATH may be shared
76
+ // and keeps its mode), only when this process owns it; clears group/other bits.
80
77
  function tightenOwnDir(dir) {
81
78
  if (process.platform === 'win32')
82
79
  return;
@@ -110,9 +107,8 @@ export function buildMethodIndex(doc, api) {
110
107
  if (!method.id || !method.httpMethod || !method.path)
111
108
  continue;
112
109
  const params = method.parameters ?? {};
113
- // Google serves parameters{} with unstable key order between fetches;
114
- // parameterOrder is the canonical sequence, so sort by it (alphabetical
115
- // tie-break) to keep gen-tools output deterministic.
110
+ // parameters{} has unstable key order between fetches; sort by the
111
+ // canonical parameterOrder (alphabetical tie-break) for deterministic output.
116
112
  const order = method.parameterOrder ?? [];
117
113
  const pos = (n) => {
118
114
  const i = order.indexOf(n);
@@ -174,9 +170,8 @@ export async function loadMethodIndex(api, deps = {}) {
174
170
  if (cacheFresh())
175
171
  doc = readCache();
176
172
  if (!doc) {
177
- // Newer APIs (analyticsadmin/analyticsdata) are absent from the central
178
- // discovery directory (404); each service's own $discovery endpoint is
179
- // authoritative, so try both — same fallback as scripts/fetch-discovery.
173
+ // Newer APIs are absent from the central discovery directory (404); try
174
+ // each service's own $discovery endpoint too, as scripts/fetch-discovery does.
180
175
  const urls = [
181
176
  `https://www.googleapis.com/discovery/v1/apis/${spec.id}/${spec.version}/rest`,
182
177
  `https://${spec.id}.googleapis.com/$discovery/rest?version=${spec.version}`,
@@ -217,20 +212,11 @@ export function clearDiscoveryMemoryCache() {
217
212
  // GA4-style report execution (runReport, batchRunPivotReports, runAccessReport)
218
213
  // and check* predicates are POSTs purely for the request-body size — reads.
219
214
  const POST_READ_VERB = /^(get|list|search|query|lookup|count|batchGet|generateIds|export|download|inspect|check|suggest|(batch)?run\w*report)/i;
220
- // `cancelWipe` must be matched here, BEFORE `cancel` reaches the delete list:
221
- // it calls off a pending wipe, which is the opposite of one. Update is tested
222
- // before delete for exactly that reason.
223
- // State transitions on something that already exists: reversible, and none of
224
- // them creates anything, so `create` was both wrong and the most permissive
225
- // class available. `(batch)?` mirrors the delete list, without which every
226
- // `batchUpdate*` POST fell through to `create`.
215
+ // Update, tested before delete, so cancelWipe (calling off a wipe) doesn't
216
+ // reach the delete list via its "cancel" suffix. See docs/internals.md.
227
217
  const POST_UPDATE_VERB = /^(cancelWipe|(batch)?(untrash|undelete|restore|modify|move|set|sort|merge|unmerge|replace|resize|publish|resolve|update|patch|write|format|change|close|reopen|enable|disable|hide|unhide|accept|approve|decline|reassign|reactivate|renew|mark|turn|suspend|activate|make|return|reclaim|complete))/i;
228
- // archive sits with the deletes: in GA4 archiving a custom dimension/metric is
229
- // permanent, so the most restrictive write class is the safe classification.
230
- // So do the teardown verbs: `stop` and `cancel` tear down a push channel or a
231
- // long-running operation, `wipe`/`invalidate`/`signOut` destroy device data,
232
- // codes and sessions, `reset` discards a configuration, and `end` terminates a
233
- // live conference. Each removes something that existed.
218
+ // archive/wipe/reset/stop/cancel/end etc. all remove or permanently discard
219
+ // something that existed, so the most restrictive class is the safe one. See docs/internals.md.
234
220
  const POST_DELETE_VERB = /^(batch)?(delete|remove|trash|clear|empty|obliterate|purge|revoke|wipeout|archive|wipe|invalidate|signOut|stop|cancel|unregister|unreserve|reset|end)/i;
235
221
  export function cudFromMethod(method) {
236
222
  switch (method.httpMethod) {
@@ -262,13 +248,8 @@ export function expandPath(template, pathParams) {
262
248
  if (value === undefined) {
263
249
  throw new Error(`Missing required path parameter "${name}"`);
264
250
  }
265
- // A blank segment collapses the path and Google routes the resulting
266
- // trailing-slash URL to the COLLECTION: `sites.get` with siteUrl:"" came
267
- // back as the whole site LIST, reported as success. Dot segments resolve
268
- // at URL-parse time, so `../v1/contactGroups` retargets the call at a
269
- // different endpoint and returns ITS response as success. Both are
270
- // silent-wrong, so refuse locally rather than send a different request.
271
- // `{+param}` keeps its slashes, so every segment has to be checked.
251
+ // Both a blank segment and a "." / ".." segment silently retarget the
252
+ // request to a different, successfully-answered endpoint. See docs/internals.md.
272
253
  const segments = plus ? String(value).split('/') : [String(value)];
273
254
  for (const segment of segments) {
274
255
  if (segment.trim() === '') {
package/dist/doctor.d.ts CHANGED
@@ -55,7 +55,7 @@ export interface HttpProbeResult {
55
55
  /** Console deep-link to enable one API (section-6 hint). */
56
56
  export declare function apiEnableLink(api: string): string;
57
57
  /** One line, state AND source, so the people being measured can see both
58
- * here and in `diagnose` (metrics spec section 2). Read-only. */
58
+ * here and in `diagnose`. Read-only. */
59
59
  export declare function usageMetricsStatusLine(env: Record<string, string | undefined>): string;
60
60
  /** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
61
61
  export declare function overallVerdict(sections: DiagnosticSection[]): Verdict;