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
@@ -25,10 +25,8 @@ function headerOf(error, name) {
25
25
  const key = Object.keys(h).find((k) => k.toLowerCase() === name);
26
26
  return key === undefined ? undefined : String(h[key]);
27
27
  }
28
- /** gaxios hands `response.data` back as a STRING whenever it could not parse
29
- * JSON, and on every non-2xx of a responseType:'stream' call (drive_download,
30
- * drive_export), where it buffers the whole body to utf8 before throwing. So
31
- * the JSON error of a failed download is invisible to a plain property read. */
28
+ // gaxios hands response.data back as a STRING whenever it can't parse JSON,
29
+ // including every non-2xx of a responseType:'stream' call (drive_download/export).
32
30
  function errorPayload(error) {
33
31
  const data = error?.response?.data;
34
32
  if (data && typeof data === 'object')
@@ -48,9 +46,8 @@ function errorPayload(error) {
48
46
  return undefined;
49
47
  }
50
48
  }
51
- /** AIP-193 google.rpc.ErrorInfo. Modern Workspace APIs (Chat, Meet, Workspace
52
- * Events, Analytics Data, People) put the machine-readable reason here and
53
- * leave errors[0].reason empty, which is why their 403s used to fall through. */
49
+ // AIP-193 google.rpc.ErrorInfo: modern Workspace APIs put the machine-readable
50
+ // reason here and leave errors[0].reason empty, which is why their 403s used to fall through.
54
51
  function errorInfoOf(payload) {
55
52
  const details = payload?.error?.details;
56
53
  if (!Array.isArray(details))
@@ -71,10 +68,8 @@ function reasonOf(error, payload) {
71
68
  payload?.error?.status);
72
69
  }
73
70
  const MARKUP_START_RE = /^\uFEFF?\s*(?:<!--|<!\s*doctype\b|<\?xml\b|<\/?(?:html|head|body|meta|title|style|script|h[1-6]|div|p|pre|center|table|font)\b)/i;
74
- /** True when error.message is a response BODY rather than an error sentence.
75
- * Content-Type first, shape second, markup sniff last, anchored at the start.
76
- * NEVER matches page TEXT: the same URL answers "Page introuvable" to a
77
- * French-locale account and "Page not found" to anonymous curl. */
71
+ // True when error.message is a response BODY, not an error sentence; never
72
+ // matches page TEXT (a localized "Page introuvable" must still pass through).
78
73
  function isRawBody(error, message) {
79
74
  const ct = headerOf(error, 'content-type')?.toLowerCase();
80
75
  if (ct)
@@ -108,17 +103,15 @@ export function safeMessage(error, payload = errorPayload(error), status = statu
108
103
  const raw = typeof error?.message === 'string' ? error.message : String(error);
109
104
  return isRawBody(error, raw) ? bodyPlaceholder(error, status, raw.length) : capMessage(raw);
110
105
  }
111
- // Connect/DNS syscall codes. ENOTFOUND (no such name) is the one non-transient
112
- // member. node-fetch flattens the happy-eyeballs AggregateError to a bare code
113
- // with an empty message, so the code is the only surviving signal to surface.
106
+ // Connect/DNS syscall codes; ENOTFOUND is the one non-transient member. The
107
+ // code is the only surviving signal once node-fetch flattens the AggregateError.
114
108
  const RETRIABLE_NET_CODES = new Set([
115
109
  'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'ECONNABORTED', 'ENETUNREACH',
116
110
  'EHOSTUNREACH', 'EPIPE', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET',
117
111
  ]);
118
112
  const NET_CODES = new Set([...RETRIABLE_NET_CODES, 'ENOTFOUND']);
119
- // Local-filesystem syscall codes from caller-supplied paths (localPath/savePath).
120
- // String codes, so they never collide with Google's numeric statuses; the
121
- // network codes above are deliberately excluded.
113
+ // Local-filesystem syscall codes (never collide with Google's numeric
114
+ // statuses); the network codes above are deliberately excluded.
122
115
  const LOCAL_FS_CODES = new Set(['ENOENT', 'EACCES', 'EISDIR', 'ENOTDIR', 'EPERM', 'ELOOP', 'ENAMETOOLONG', 'ENOSPC']);
123
116
  /** First known network code on the error or its cause chain (GaxiosError.cause
124
117
  * -> FetchError; undici TypeError.cause -> AggregateError.errors). */
@@ -134,7 +127,7 @@ function netCodeOf(error) {
134
127
  }
135
128
  return undefined;
136
129
  }
137
- /** Console deep-link to enable one API (noob-proofing hint, B10). */
130
+ /** Console deep-link to enable one API (noob-proofing hint). */
138
131
  function apiEnableLink(api) {
139
132
  return `https://console.cloud.google.com/apis/library/${api}.googleapis.com`;
140
133
  }
@@ -156,8 +149,7 @@ const FORBIDDEN_RATE_REASONS = new Set([
156
149
  'sharingRateLimitExceeded', 'quotaExceeded', 'RATE_LIMIT_EXCEEDED', 'RESOURCE_EXHAUSTED',
157
150
  ]);
158
151
  // Google already answered correctly: the file type is wrong for the tool, not
159
- // the permissions. The escape hatch has said this for years (executor.ts); the
160
- // curated pair used to discard it and blame sharing instead.
152
+ // the permissions; the curated pair used to discard that and blame sharing instead.
161
153
  const WRONG_TOOL_HINTS = {
162
154
  fileNotDownloadable: 'This is a Google Workspace native file (Doc, Sheet, Slides): it has no binary content to download. Use drive_export with a target mimeType such as application/pdf.',
163
155
  fileNotExportable: 'Export only works on Google Workspace native files. This one already has binary content: use drive_download to save it as is.',
@@ -194,9 +186,8 @@ function rateLimited(error, message, account, reason) {
194
186
  account,
195
187
  };
196
188
  }
197
- /** Prefer AIP metadata (service + consumer) over scraping the message: the
198
- * scrape stays as the fallback because Google's JSON error text is English
199
- * only, unlike its localized HTML pages. */
189
+ // Prefer AIP metadata over scraping the message: the scrape stays as fallback
190
+ // since Google's JSON error text is English only, unlike its localized HTML pages.
200
191
  function enableHint(info, message) {
201
192
  const service = info?.metadata?.service;
202
193
  const project = info?.metadata?.consumer?.replace(/^projects\//, '');
@@ -216,9 +207,7 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
216
207
  const message = safeMessage(error, payload, status);
217
208
  const hints = normalizeHints(serviceHints);
218
209
  // 7-day Testing-mode trap: a BYO OAuth client in "Testing" status expires
219
- // refresh tokens weekly; the dead token surfaces as invalid_grant. Name the
220
- // real fix (publish to production) so the agent stops looping on re-auth.
221
- // Checked before the generic 401 so it wins on either 400 or 401.
210
+ // refresh tokens weekly; checked before the generic 401 so it wins on either 400 or 401.
222
211
  const rawError = payload?.error;
223
212
  const grantHaystack = [message, reason, typeof rawError === 'string' ? rawError : '', payload?.error_description]
224
213
  .filter(Boolean)
@@ -266,9 +255,8 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
266
255
  });
267
256
  return { error: 'api_not_enabled', message, hint: rendered?.hint ?? enableHint(info, message), retriable: false, account };
268
257
  }
269
- // The scope hint fires ONLY on the machine-readable token. A coarse AIP
270
- // status of PERMISSION_DENIED is equally true of a resource denial, and
271
- // branching on it is what told callers to add a bundle they already had.
258
+ // Fires ONLY on the machine-readable token: a coarse PERMISSION_DENIED is
259
+ // equally true of a resource denial, and branching on it told callers to add a bundle they already had.
272
260
  const scopeIssue = reason === 'ACCESS_TOKEN_SCOPE_INSUFFICIENT' ||
273
261
  reason === 'insufficientPermissions' ||
274
262
  /\bACCESS_TOKEN_SCOPE_INSUFFICIENT\b/.test(message);
@@ -334,9 +322,8 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
334
322
  account,
335
323
  };
336
324
  }
337
- // A caller-side 4xx and a Google outage used to share `upstream_error`, so a
338
- // consumer keying on the slug could not tell "your request is wrong, never
339
- // retry" from "Google is having a bad minute, do retry".
325
+ // Used to share `upstream_error` with a 5xx, so a consumer keying on the
326
+ // slug couldn't tell "never retry" from "do retry".
340
327
  if (status !== undefined && status >= 400) {
341
328
  return {
342
329
  error: 'bad_request',
@@ -397,18 +384,14 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
397
384
  hint: rendered?.hint ??
398
385
  (`Network failure (${netCode}) before reaching Google - not an auth or API problem. Usually transient: retry. ` +
399
386
  'If it persists on a high-latency or broken-IPv6 link, raise the happy-eyeballs budget: NODE_OPTIONS=--network-family-autoselection-attempt-timeout=4000 (server default 2000ms), and check connectivity with curl.'),
400
- // Not an override site (C5 is the only kind whose retriable the host
401
- // may flip): a multi-tenant host keeps core's retriable codes for
402
- // network_error.
387
+ // Not an override site: a multi-tenant host keeps core's retriable codes for network_error.
403
388
  retriable: RETRIABLE_NET_CODES.has(netCode),
404
389
  account,
405
390
  };
406
391
  }
407
392
  }
408
- // A gaxios-shaped error carries `response` or `config`, so the request left
409
- // this process. Anything else threw INSIDE the server (a TypeError in a
410
- // handler, a missing key), and calling that `upstream_error` blamed Google
411
- // for our own bug: an empty messageId used to surface exactly that way.
393
+ // A gaxios-shaped error means the request left this process; anything else
394
+ // threw INSIDE the server, and calling that `upstream_error` blamed Google for our own bug.
412
395
  if (error?.response !== undefined || error?.config !== undefined) {
413
396
  return {
414
397
  error: 'upstream_error',
@@ -21,19 +21,15 @@ export function hostFilesRefused(account, what) {
21
21
  // path.basename() is a traversal guard — a caller-supplied filename must never escape savePath.
22
22
  export function prepareLocalDest(savePath, filename) {
23
23
  const name = path.basename(filename);
24
- // Agents routinely pass the intended FILE path as savePath and repeat the
25
- // name in `filename`; a blind join would mkdir a directory named like the
26
- // file and bury the download inside it, so strip the duplicated leaf.
24
+ // Agents routinely repeat the leaf in both savePath and filename; a blind
25
+ // join would bury the download in a directory named like the file itself.
27
26
  const dir = path.basename(savePath) === name ? path.dirname(savePath) : savePath;
28
27
  const dest = path.join(dir, name);
29
28
  fs.mkdirSync(dir, { recursive: true });
30
29
  return dest;
31
30
  }
32
- // fs.createReadStream() reports an unopenable path as an async 'error' EVENT;
33
- // with no listener attached, that single event kills the whole process — fatal
34
- // for the shared HTTP transport. Opening the fd first turns the open-failure
35
- // class (ENOENT/EACCES/...) into a normal rejection the caller's try/catch can
36
- // map to an error envelope.
31
+ // createReadStream's open failure surfaces as an unhandled 'error' event that
32
+ // kills the process; opening the fd first turns it into a normal rejection.
37
33
  export async function openLocalReadStream(localPath) {
38
34
  const handle = await fs.promises.open(localPath, 'r');
39
35
  // open() succeeds on a directory; fail it here rather than as an async read error.
@@ -9,8 +9,9 @@ export interface AddForm {
9
9
  otherBundles: string;
10
10
  admin: boolean;
11
11
  }
12
- /** Every optional bundle (admin is granted via its own checkbox, not here). */
13
- export declare function allOptionalBundles(): string[];
12
+ /** Every optional bundle (admin is granted via its own checkbox, not here),
13
+ * less those Google refuses for the account at `email` when one is given. */
14
+ export declare function allOptionalBundles(email?: string): string[];
14
15
  /** elicitation/create form schema (flat primitives only, per the MCP spec). */
15
16
  export declare function addFormSchema(): Record<string, unknown>;
16
17
  export type AddValidation = {
@@ -53,7 +54,7 @@ export declare function argsToAddForm(a: {
53
54
  /** Scopes requested by the profile but NOT granted at consent (granular
54
55
  * consent / unchecked bundles). Pure. */
55
56
  export declare function scopeGrantDiff(requested: string[], grantedScope: string | undefined): string[];
56
- /** HTTP-transport consent context (S1.20): set by the HTTP bootstrap. When
57
+ /** HTTP-transport consent context: set by the HTTP bootstrap. When
57
58
  * present, runConsent NEVER binds a server-side loopback listener — the
58
59
  * user's browser cannot reach the daemon's loopback — and instead hands out
59
60
  * a clientless AS consent URL. The injector decides the flow (single-owner =
@@ -3,19 +3,23 @@ import { getAccountSet, invalidateAccountSet, refreshAccountSetIfStale } from '.
3
3
  import { ALIAS_RE, mutateConfigFile } from '../config-file.js';
4
4
  import { writeToken } from '../token-store.js';
5
5
  import { resolveScopesForAccount } from '../auth.js';
6
- import { BUNDLE_CATALOG, closestBundle, isKnownBundle, resolveBundleAliases } from '../scope-catalog.js';
6
+ import { BUNDLE_CATALOG, closestBundle, isKnownBundle, refusedBundlesFor, resolveBundleAliases } from '../scope-catalog.js';
7
7
  import { openUrl } from '../open-url.js';
8
8
  import { coerceBoolean } from './_coerce.js';
9
9
  import { safeMessage, stringifyEnvelope } from './_errors.js';
10
10
  import { buildConsentClient, openLoopbackConsent, hasClientCredentials, TESTING_MODE_WARNING, } from '../oauth-consent.js';
11
11
  import { detectClients, buildServerEntry, renderInstruction, applyFileEntry, resolveMode, DEFAULT_SERVER_NAME, } from '../client-config.js';
12
- // The common optional bundles get their own checkbox (MCP elicitation has no
13
- // multi-select, so a boolean per bundle IS the checklist); the long tail stays
14
- // a comma-separated field for power users, and `allBundles` grants every one.
12
+ // Each common bundle gets its own checkbox since MCP elicitation has no
13
+ // multi-select; the long tail stays a comma-separated field, `allBundles` grants all.
15
14
  const CHECKBOX_BUNDLES = ['forms', 'chat'];
16
- /** Every optional bundle (admin is granted via its own checkbox, not here). */
17
- export function allOptionalBundles() {
18
- return Object.keys(BUNDLE_CATALOG).filter((n) => n !== 'admin');
15
+ /** Every optional bundle (admin is granted via its own checkbox, not here),
16
+ * less those Google refuses for the account at `email` when one is given. */
17
+ export function allOptionalBundles(email) {
18
+ const all = Object.keys(BUNDLE_CATALOG).filter((n) => n !== 'admin');
19
+ if (email === undefined)
20
+ return all;
21
+ const refused = new Set(refusedBundlesFor(email, all));
22
+ return all.filter((n) => !refused.has(n));
19
23
  }
20
24
  /** elicitation/create form schema (flat primitives only, per the MCP spec). */
21
25
  export function addFormSchema() {
@@ -48,8 +52,7 @@ export function validateAddForm(input, existingAliases) {
48
52
  return { ok: false, slug: 'E_VALIDATION', message: 'Email is required.', hint: "It is used as the Google login hint, so it must be the account's real Google address.", alias };
49
53
  }
50
54
  if (input.allBundles === true) {
51
- // "All optional scopes" supersedes the individual picks.
52
- return { ok: true, alias, email, bundles: allOptionalBundles(), admin: input.admin === true };
55
+ return { ok: true, alias, email, bundles: allOptionalBundles(email), admin: input.admin === true };
53
56
  }
54
57
  const picked = CHECKBOX_BUNDLES.filter((b) => input[b] === true);
55
58
  const rawOther = (input.otherBundles ?? '').split(',').map((s) => s.trim()).filter(Boolean);
@@ -70,6 +73,16 @@ export function validateAddForm(input, existingAliases) {
70
73
  };
71
74
  }
72
75
  }
76
+ const refused = refusedBundlesFor(email, bundles);
77
+ if (refused.length > 0) {
78
+ return {
79
+ ok: false,
80
+ slug: 'E_VALIDATION',
81
+ message: `Google does not let a personal account grant ${refused.map((b) => `"${b}"`).join(', ')}.`,
82
+ hint: 'Leave it out, or link a Google Workspace account for it.',
83
+ alias,
84
+ };
85
+ }
73
86
  return { ok: true, alias, email, bundles, admin: input.admin === true };
74
87
  }
75
88
  /** URL-mode elicitation params. `elicitationId` is REQUIRED by the spec
@@ -102,8 +115,6 @@ export function scopeGrantDiff(requested, grantedScope) {
102
115
  const granted = new Set((grantedScope ?? '').split(' ').filter(Boolean));
103
116
  return requested.filter((s) => !granted.has(s));
104
117
  }
105
- /** Persist a new account row (+ a per-account scope profile when bundles/admin
106
- * were chosen) through the atomic registry mutation path (BR2). */
107
118
  function writeAccountRow(alias, email, bundles, admin) {
108
119
  mutateConfigFile((current) => {
109
120
  const next = { ...current, accounts: { ...(current.accounts ?? {}) } };
@@ -121,9 +132,8 @@ function writeAccountRow(alias, email, bundles, admin) {
121
132
  function textResult(text) {
122
133
  return { content: [{ type: 'text', text }] };
123
134
  }
124
- /** Wizard failures carry the same envelope as every other tool's, so a client
125
- * can branch on the slug and metrics can classify them. Success stays prose:
126
- * it is a human-readable onboarding report, not data. */
135
+ // Errors use the shared envelope so clients can branch on slug; success stays
136
+ // plain prose (a human-readable report, not data).
127
137
  function errorResult(slug, message, hint, account) {
128
138
  return {
129
139
  content: [{ type: 'text', text: stringifyEnvelope({
@@ -140,9 +150,8 @@ let httpConsent = null;
140
150
  export function setWizardHttpConsent(ctx) {
141
151
  httpConsent = ctx;
142
152
  }
143
- /** Services register their tools at startup, so one first enabled by this
144
- * account stays unregistered until a restart; say so rather than claim the
145
- * whole account works without one. */
153
+ // Services register tools at startup: an account's first-enabled service
154
+ // stays unregistered until a restart, so say so rather than claim it just works.
146
155
  function restartNote(pending) {
147
156
  if (pending.length === 0)
148
157
  return '';
@@ -155,9 +164,6 @@ function pendingText(alias, url, pending) {
155
164
  `On completion the token is stored server-side and the account is usable immediately — no restart. ` +
156
165
  `If the link expires, run account_reauth for a fresh one.${restartNote(pending)}\n${TESTING_MODE_WARNING}`);
157
166
  }
158
- /** Run Google consent for `alias` and persist the token. Opens the browser via
159
- * URL-mode elicitation when the client supports it, else server-side + prints
160
- * the URL. Returns the granted-scope diff for the S4 report. */
161
167
  async function runConsent(server, alias) {
162
168
  const { randomBytes } = await import('node:crypto');
163
169
  const cfg = getAccountSet().configs[alias];
@@ -169,9 +175,8 @@ async function runConsent(server, alias) {
169
175
  hint: 'Accounts sourced from GOOGLE_ACCOUNTS are not editable here. Run `mcp-google-multi migrate-config` to move them into config.json.',
170
176
  };
171
177
  }
172
- // HTTP transport: consent completes OUT OF BAND at the AS /callback. Never
173
- // bind a loopback listener here; try URL-mode elicitation as a convenience
174
- // but surface the URL UP FRONT either way (not only on failure).
178
+ // HTTP: consent completes out-of-band at the AS /callback; never bind a
179
+ // loopback listener; surface the URL up front, elicitation is just a convenience.
175
180
  const http = httpConsent;
176
181
  if (http) {
177
182
  let url;
@@ -187,14 +192,13 @@ async function runConsent(server, alias) {
187
192
  await server.server.elicitInput(urlElicitationParams(alias, url, randomBytes(16).toString('hex')));
188
193
  }
189
194
  catch {
190
- // the URL in the pending text is the recovery
195
+ // pending text has the recovery URL
191
196
  }
192
197
  }
193
198
  return { ok: 'pending', url };
194
199
  }
195
- // Bind the ephemeral loopback listener BEFORE building the auth URL: the
196
- // redirect URI needs the assigned port, and listening first means the
197
- // callback can't race the browser.
200
+ // Bind the loopback listener before building the auth URL: the redirect URI
201
+ // needs the assigned port, and listening first avoids a race with the callback.
198
202
  const loop = await openLoopbackConsent();
199
203
  const client = buildConsentClient(loop.redirect);
200
204
  const expectedState = randomBytes(32).toString('hex');
@@ -207,9 +211,8 @@ async function runConsent(server, alias) {
207
211
  let opened = false;
208
212
  if (caps?.elicitation?.url !== undefined) {
209
213
  try {
210
- // elicitationId is REQUIRED by the spec schema; omitting it made every
211
- // url-mode request fail client-side validation, so this branch always
212
- // fell through to the server-side browser open.
214
+ // elicitationId is REQUIRED by the spec; omitting it silently fails
215
+ // client-side validation, falling through to the server-side browser open.
213
216
  const r = await server.server.elicitInput(urlElicitationParams(alias, url, randomBytes(16).toString('hex')));
214
217
  if (r.action !== 'accept') {
215
218
  loop.close();
@@ -254,9 +257,8 @@ function s4Text(alias, missing, pending) {
254
257
  }
255
258
  const REQUIRES_INTERACTION = { 'anthropic/requiresUserInteraction': true };
256
259
  export function registerAccountWizardTools(registry, server, awaitingRestart = () => []) {
257
- // Registered as META (always-visible, like account_list) so onboarding tools
258
- // are reachable in lazy mode without discover_all first; registerMeta still
259
- // preserves the requiresUserInteraction clientMeta and skips the fan-out path.
260
+ // Registered as meta (like account_list) so onboarding tools are reachable in
261
+ // lazy mode without discover_all; this still preserves requiresUserInteraction and skips fan-out.
260
262
  const registerMeta = registry.registerMeta;
261
263
  registerMeta('account_add', {
262
264
  _meta: REQUIRES_INTERACTION,
@@ -272,18 +274,16 @@ export function registerAccountWizardTools(registry, server, awaitingRestart = (
272
274
  },
273
275
  }, async (args) => {
274
276
  try {
275
- // Env-sourced registry: GOOGLE_ACCOUNTS is the exclusive source and
276
- // config.json accounts are ignored, so a wizard add would be a phantom
277
- // write. Refuse up front (BR-10) rather than write a row nothing reads.
277
+ // GOOGLE_ACCOUNTS is the exclusive registry source when set; config.json
278
+ // is ignored, so refuse here rather than write a row nothing reads.
278
279
  if (process.env.GOOGLE_ACCOUNTS?.trim()) {
279
280
  return errorResult('E_ENV_ACCOUNTS_MODE', 'Accounts are defined by the GOOGLE_ACCOUNTS environment variable, so a new account cannot be added interactively: config.json is ignored while it is set, and the row would be written where nothing reads it.', 'Either add the alias to GOOGLE_ACCOUNTS and run account_reauth, or run `mcp-google-multi migrate-config` to move accounts into config.json and unset GOOGLE_ACCOUNTS.');
280
281
  }
281
282
  if (!hasClientCredentials()) {
282
283
  return errorResult('invalid_client', 'GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are not set, so there is no OAuth client to run consent against.', 'Run the `setup` prompt (/mcp__google-multi__setup) to create an OAuth client, then set them. See docs/google-cloud-setup.md.');
283
284
  }
284
- // S1: collect the registry row. Arguments win over the form so the
285
- // wizard still works in clients without form elicitation (where the
286
- // interactive path used to dead-end).
285
+ // Arguments win over the form so the wizard still works for clients
286
+ // without form elicitation support.
287
287
  const a = (args ?? {});
288
288
  let input;
289
289
  if (a.alias?.trim() || a.account?.trim() || a.email?.trim()) {
@@ -304,25 +304,22 @@ export function registerAccountWizardTools(registry, server, awaitingRestart = (
304
304
  const validated = validateAddForm(input, getAccountSet().aliases);
305
305
  if (!validated.ok)
306
306
  return errorResult(validated.slug, validated.message, validated.hint, validated.alias);
307
- // S2: atomic write + make the alias callable without a restart (BR3).
307
+ // Atomic write + make the alias callable without a restart.
308
308
  writeAccountRow(validated.alias, validated.email, validated.bundles, validated.admin);
309
309
  invalidateAccountSet();
310
- // Live account args validate against the refreshed registry already;
311
- // the list_changed nudge makes clients re-fetch tools/list, where the
312
- // advertised account enums are rebuilt from the live set.
310
+ // Account args already validate against the refreshed registry; the
311
+ // nudge just gets clients to re-fetch tools/list for the new account enum.
313
312
  try {
314
313
  server.sendToolListChanged();
315
314
  }
316
315
  catch {
317
- // a client that cannot receive notifications loses nothing but the nudge
316
+ // harmless if unsupported
318
317
  }
319
- // S3 + S4: consent + validate.
320
318
  const consent = await runConsent(server, validated.alias);
321
319
  if (consent.ok === 'pending')
322
320
  return textResult(pendingText(validated.alias, consent.url, awaitingRestart()));
323
321
  if (!consent.ok)
324
322
  return errorResult(consent.slug, consent.message, consent.hint, validated.alias);
325
- // B15: offer to register the server with another MCP client.
326
323
  return textResult(`${s4Text(validated.alias, consent.missing, awaitingRestart())}\nTip: run account_write_config to register this server with another MCP client (Claude Desktop / Cursor / Claude Code).`);
327
324
  }
328
325
  catch (e) {
@@ -339,10 +336,8 @@ export function registerAccountWizardTools(registry, server, awaitingRestart = (
339
336
  // Plain string (NOT the account enum) so this never joins the
340
337
  // multi-account fan-out path; validated against the registry below.
341
338
  alias: z.string().min(1).optional().describe('Existing account alias to re-authenticate'),
342
- // Every other tool in the server spells this `account`, so that is
343
- // what a caller reaches for. Accepting both removes a dead end that
344
- // no did-you-mean can rescue: `account` is 5 edits from `alias`, so
345
- // the matcher cannot bridge them.
339
+ // Every other tool spells this `account`; accepting both avoids a dead
340
+ // end no did-you-mean can bridge (too many edits from `alias`).
346
341
  account: z.string().min(1).optional().describe('Alias of the account to re-authenticate (same as `alias`)'),
347
342
  },
348
343
  }, async (args) => {
@@ -391,7 +386,7 @@ export function registerAccountWizardTools(registry, server, awaitingRestart = (
391
386
  resourceUri = m.resourceUri;
392
387
  }
393
388
  catch {
394
- // stdio fallback on any config error
389
+ // fallback to stdio on error
395
390
  }
396
391
  let entry;
397
392
  try {
@@ -412,9 +407,8 @@ export function registerAccountWizardTools(registry, server, awaitingRestart = (
412
407
  blocks.push(`${client.label}: run\n ${instr.text}`);
413
408
  }
414
409
  else if (a.write && mode === 'http') {
415
- // Over HTTP this tool runs on the DAEMON, whose homedir is not the
416
- // caller's machine: write:true would edit the operator's own client
417
- // configs (a cross-tenant hazard under tenancy). Snippet only.
410
+ // Over HTTP this runs on the daemon, not the caller's machine: write:true
411
+ // would edit the operator's own client configs (a tenancy hazard), so snippet only.
418
412
  blocks.push(`${client.label}: write:true is ignored over HTTP — this tool runs on the server, not your machine. Add to ${instr.path}\n${instr.text}`);
419
413
  }
420
414
  else if (a.write) {
@@ -12,7 +12,7 @@ export interface AccountHealthDeps {
12
12
  fileExists: (p: string) => boolean;
13
13
  now: () => number;
14
14
  /** Scopes required by registered tools — feeds doctor's tool-grained
15
- * "registered but not requestable" view (B9). account_list stays compact:
15
+ * "registered but not requestable" view. account_list stays compact:
16
16
  * its report universe is profile ∪ granted only. */
17
17
  registeredScopes?: () => string[];
18
18
  /** Account view health is derived against; absent = the global registry. */
@@ -22,7 +22,7 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
22
22
  const config = set.configs[alias];
23
23
  const admin = getAdminAccounts(set).includes(alias);
24
24
  const configured = resolveScopesForAccount(alias, set);
25
- // BR-10: env-sourced accounts are not persisted in config.json, so the
25
+ // Env-sourced accounts are not persisted in config.json, so the
26
26
  // account wizard cannot edit them — the deployer edits env instead.
27
27
  const base = {
28
28
  alias,
@@ -6,9 +6,8 @@ import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError, invalidParams } from './_errors.js';
7
7
  // Admin SDK requires Workspace super-admin (or delegated admin) on the account — personal @gmail.com accounts 403 on every endpoint.
8
8
  export function registerAdminTools(server, deps = {}) {
9
- // Per-registry, LIVE account enum + injectable client (S1.10): the
10
- // schema follows the registry's account view at parse time, and the
11
- // custody path is the context's, not the process global.
9
+ // Per-registry, live account enum + injectable client: the schema follows
10
+ // the registry's account view at parse time, not a process-global default.
12
11
  const accountEnum = accountArgLive(() => server.accountAliases()).optional();
13
12
  const getClientFn = deps.getClientFn ?? getClient;
14
13
  // ─── Reports / audit log ───────────────────────────────────────────────
@@ -105,9 +105,8 @@ const propertySchema = z
105
105
  .string()
106
106
  .describe('GA4 property: numeric ID like "213025502" or "properties/213025502" — NOT a "G-..." measurement ID and not "UA-...". Find yours with analytics_account_summaries.');
107
107
  export function registerAnalyticsTools(server, deps = {}) {
108
- // Per-registry, LIVE account enum + injectable client (S1.10): the
109
- // schema follows the registry's account view at parse time, and the
110
- // custody path is the context's, not the process global.
108
+ // Per-registry, live account enum + injectable client: the schema follows
109
+ // the registry's account view at parse time, not a process-global default.
111
110
  const accountEnum = accountArgLive(() => server.accountAliases()).optional();
112
111
  const getClientFn = deps.getClientFn ?? getClient;
113
112
  server.registerTool('analytics_account_summaries', {
@@ -281,8 +280,8 @@ function invalidProperty(hint, account) {
281
280
  export function handleAnalyticsError(error, account) {
282
281
  return handleGoogleApiError(error, account, {
283
282
  scope: 'Needs the "analytics" bundle on this account: add it to the scope profile, then re-auth.',
284
- // GA4 answers PERMISSION_DENIED for a property ACL too, and the old
285
- // combined hint sent those callers to re-auth a scope they already had.
283
+ // GA4 returns PERMISSION_DENIED for a property-access issue too, not just
284
+ // a missing scope; keep the hints distinct, or callers get sent to re-auth a scope they already have.
286
285
  resource: 'The scope is not the problem: this Google account has no access to that GA4 property. Grant it in GA4 Admin > Property Access Management, or call analytics_account_summaries to see the properties it can read.',
287
286
  bundle: 'analytics',
288
287
  });
@@ -7,9 +7,8 @@ import { checkOutbound } from '../outbound-allowlist.js';
7
7
  import { handleGoogleApiError, invalidParams } from './_errors.js';
8
8
  import { listResult, sliceClean } from '../trim.js';
9
9
  export function registerCalendarTools(server, deps = {}) {
10
- // Per-registry, LIVE account enum + injectable client (S1.10): the
11
- // schema follows the registry's account view at parse time, and the
12
- // custody path is the context's, not the process global.
10
+ // Per-registry, live account enum + injectable client: the schema follows
11
+ // the registry's account view at parse time, not a process-global default.
13
12
  const accountEnum = accountArgLive(() => server.accountAliases()).optional();
14
13
  const getClientFn = deps.getClientFn ?? getClient;
15
14
  server.registerTool('calendar_list_calendars', {
@@ -382,10 +381,8 @@ export function registerCalendarTools(server, deps = {}) {
382
381
  },
383
382
  });
384
383
  return {
385
- // freebusy answers 200 with NO `calendars` key on an empty items
386
- // list, and JSON.stringify(undefined) returns undefined, not a
387
- // string: the SDK then rejected our own result with -32602. The
388
- // value is a map keyed by calendar id, so the fallback is {}.
384
+ // Empty items returns 200 with no `calendars` key; JSON.stringify(undefined)
385
+ // would make the SDK reject our own result, so fall back to {}.
389
386
  content: [{ type: 'text', text: JSON.stringify(res.data.calendars ?? {}, null, 2) }],
390
387
  };
391
388
  }
@@ -5,9 +5,8 @@ import { accountArgLive } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError, invalidParams } from './_errors.js';
7
7
  export function registerChatTools(server, deps = {}) {
8
- // Per-registry, LIVE account enum + injectable client (S1.10): the
9
- // schema follows the registry's account view at parse time, and the
10
- // custody path is the context's, not the process global.
8
+ // Per-registry, live account enum + injectable client: the schema follows
9
+ // the registry's account view at parse time, not a process-global default.
11
10
  const accountEnum = accountArgLive(() => server.accountAliases()).optional();
12
11
  const getClientFn = deps.getClientFn ?? getClient;
13
12
  server.registerTool('chat_spaces_list', {
@@ -34,7 +34,7 @@ function formatContact(person) {
34
34
  photo: person.photos?.[0]?.url ?? '',
35
35
  };
36
36
  }
37
- /** Accent-fold + lowercase for name comparison ("Rym" == "Rÿm"). */
37
+ /** Accent-fold + lowercase for name comparison ("Zoe" == "Zoë"). */
38
38
  const foldName = (s) => s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().trim();
39
39
  /** Canonical-address preference within one contact: explicit primary, then
40
40
  * People type order work > home > other. */
@@ -52,7 +52,7 @@ function pickEmail(c) {
52
52
  return [...c.emails].sort((a, b) => emailRank(b) - emailRank(a))[0];
53
53
  }
54
54
  /** Deterministic rank tuple; higher wins, compared lexicographically so the
55
- * first discriminating rule decides (A10 tie-break table). */
55
+ * first discriminating rule decides. */
56
56
  function scoreTuple(c, foldedQuery) {
57
57
  const exact = foldName(c.displayName) === foldedQuery ? 1 : 0; // 1. exact over prefix
58
58
  const saved = c.source === 'contacts' ? 1 : 0; // 2. saved over other-contact
@@ -123,9 +123,8 @@ function dedupeCandidates(list) {
123
123
  return [...byResource.values()];
124
124
  }
125
125
  export function registerContactsTools(server, deps = {}) {
126
- // Per-registry, LIVE account enum + injectable client (S1.10): the
127
- // schema follows the registry's account view at parse time, and the
128
- // custody path is the context's, not the process global.
126
+ // Per-registry, live account enum + injectable client: the schema follows
127
+ // the registry's account view at parse time, not a process-global default.
129
128
  const accountEnum = accountArgLive(() => server.accountAliases()).optional();
130
129
  const getClientFn = deps.getClientFn ?? getClient;
131
130
  server.registerTool('contacts_search', {
@@ -170,7 +169,7 @@ export function registerContactsTools(server, deps = {}) {
170
169
  description: 'Resolve a free-text name/alias to ONE canonical email address (ranked + tie-broken), so you need not hand-expand transliteration OR-queries. Returns a single confident match, an explicit ambiguous candidate list, or null when nothing matches. For picking a recipient before gmail_send.',
171
170
  inputSchema: {
172
171
  account: accountEnum.describe('Google account alias'),
173
- name: z.string().min(1).describe('Free-text name or alias to resolve (e.g. "Rym")'),
172
+ name: z.string().min(1).describe('Free-text name or alias to resolve (e.g. "Zoe")'),
174
173
  maxCandidates: z.number().min(1).max(10).default(5).optional()
175
174
  .describe('Max candidates to return when ambiguous (1-10, default: 5)'),
176
175
  includeOtherContacts: coerceBoolean.optional()
@@ -197,7 +196,7 @@ export function registerContactsTools(server, deps = {}) {
197
196
  candidates.push(...(other.data.results ?? []).map((r) => toCandidate(r.person, 'otherContacts')));
198
197
  }
199
198
  catch {
200
- // otherContacts unavailable (scope/5xx): degrade to saved-contacts only.
199
+ // otherContacts unavailable: degrade silently
201
200
  }
202
201
  }
203
202
  const result = resolveContacts(dedupeCandidates(candidates), name, maxCandidates ?? 5);
@@ -453,8 +452,8 @@ export function registerContactsTools(server, deps = {}) {
453
452
  const members = [];
454
453
  let fetchFailures = 0;
455
454
  let firstError;
456
- // getBatchGet caps at 200 names while maxMembers goes to 1000, so a
457
- // large group used to fail outright rather than come back in pages.
455
+ // getBatchGet caps at 200 names while maxMembers goes to 1000, so fetch
456
+ // in chunks rather than fail outright on a large group.
458
457
  for (let i = 0; i < memberResourceNames.length; i += BATCH_GET_LIMIT) {
459
458
  const chunk = memberResourceNames.slice(i, i + BATCH_GET_LIMIT);
460
459
  try {
@@ -5,12 +5,8 @@ import { accountArgLive } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError, invalidParams } from './_errors.js';
7
7
  import { capText } from '../trim.js';
8
- // A single very large insertText has been observed to apply only partially while
9
- // the request still returns success, silently dropping the tail. Split a large
10
- // insert into bounded pieces sent as sequential insertText requests in one atomic
11
- // batchUpdate: small inserts keep the exact single-request path, and a large one
12
- // can no longer be silently truncated. Docs indices are UTF-16 code units (= JS
13
- // string length), so index math advances by `.length`; never split a surrogate pair.
8
+ // A single large insertText can silently apply only partially despite returning
9
+ // success; split into <=DOCS_INSERT_CHUNK sequential inserts in one atomic batchUpdate (UTF-16-safe, no split surrogate pairs).
14
10
  export const DOCS_INSERT_CHUNK = 5000;
15
11
  export function splitInsertText(text, maxLen = DOCS_INSERT_CHUNK) {
16
12
  if (text.length === 0)
@@ -170,9 +166,8 @@ export function listTabs(tabs) {
170
166
  return out;
171
167
  }
172
168
  export function registerDocsTools(server, deps = {}) {
173
- // Per-registry, LIVE account enum + injectable client (S1.10): the
174
- // schema follows the registry's account view at parse time, and the
175
- // custody path is the context's, not the process global.
169
+ // Per-registry, live account enum + injectable client: the schema follows
170
+ // the registry's account view at parse time, not a process-global default.
176
171
  const accountEnum = accountArgLive(() => server.accountAliases()).optional();
177
172
  const getClientFn = deps.getClientFn ?? getClient;
178
173
  server.registerTool('docs_create', {