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

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 (165) hide show
  1. package/dist/accounts.d.ts +17 -1
  2. package/dist/accounts.js +52 -15
  3. package/dist/api-probe.d.ts +5 -0
  4. package/dist/api-probe.js +20 -15
  5. package/dist/arg-normalize.js +6 -5
  6. package/dist/arg-strict.d.ts +7 -0
  7. package/dist/arg-strict.js +34 -4
  8. package/dist/auth.d.ts +4 -3
  9. package/dist/auth.js +11 -13
  10. package/dist/boot-gates.d.ts +26 -0
  11. package/dist/boot-gates.js +38 -0
  12. package/dist/cimd-cache.d.ts +38 -0
  13. package/dist/cimd-cache.js +117 -0
  14. package/dist/client.d.ts +32 -2
  15. package/dist/client.js +0 -0
  16. package/dist/compose.d.ts +11 -0
  17. package/dist/compose.js +109 -0
  18. package/dist/config-file.d.ts +13 -0
  19. package/dist/config-file.js +37 -1
  20. package/dist/discover.js +3 -1
  21. package/dist/discovery-client.d.ts +3 -0
  22. package/dist/discovery-client.js +25 -2
  23. package/dist/doctor.d.ts +6 -2
  24. package/dist/doctor.js +119 -23
  25. package/dist/executor.d.ts +3 -0
  26. package/dist/executor.js +4 -2
  27. package/dist/fanout.d.ts +9 -1
  28. package/dist/fanout.js +44 -10
  29. package/dist/fs-atomic.d.ts +11 -0
  30. package/dist/fs-atomic.js +219 -28
  31. package/dist/hint-provider.d.ts +129 -0
  32. package/dist/hint-provider.js +64 -0
  33. package/dist/http-transport.d.ts +62 -4
  34. package/dist/http-transport.js +343 -24
  35. package/dist/identity.d.ts +4 -1
  36. package/dist/identity.js +25 -6
  37. package/dist/index.js +66 -138
  38. package/dist/master-key.js +13 -5
  39. package/dist/mcp-token.d.ts +136 -19
  40. package/dist/mcp-token.js +569 -72
  41. package/dist/migrate-config.js +2 -2
  42. package/dist/oauth-as.d.ts +103 -3
  43. package/dist/oauth-as.js +374 -79
  44. package/dist/oauth-consent.d.ts +10 -0
  45. package/dist/oauth-consent.js +16 -0
  46. package/dist/outbound-allowlist.js +5 -2
  47. package/dist/reauth-hint.d.ts +1 -1
  48. package/dist/reauth-hint.js +12 -8
  49. package/dist/registry-transfer.d.ts +5 -0
  50. package/dist/registry-transfer.js +65 -5
  51. package/dist/registry.d.ts +18 -1
  52. package/dist/registry.js +71 -15
  53. package/dist/scope-catalog.d.ts +11 -0
  54. package/dist/scope-catalog.js +23 -6
  55. package/dist/scope-observability.d.ts +8 -6
  56. package/dist/scope-observability.js +29 -13
  57. package/dist/services.d.ts +15 -10
  58. package/dist/services.js +59 -15
  59. package/dist/ssrf-guard.d.ts +23 -9
  60. package/dist/ssrf-guard.js +101 -62
  61. package/dist/tenant-purge.d.ts +26 -0
  62. package/dist/tenant-purge.js +67 -0
  63. package/dist/token-store.d.ts +20 -2
  64. package/dist/token-store.js +0 -0
  65. package/dist/tools/_errors.d.ts +3 -0
  66. package/dist/tools/_errors.js +53 -24
  67. package/dist/tools/_local-files.d.ts +9 -0
  68. package/dist/tools/_local-files.js +18 -0
  69. package/dist/tools/account-wizard.d.ts +10 -1
  70. package/dist/tools/account-wizard.js +74 -17
  71. package/dist/tools/accounts-tool.d.ts +7 -0
  72. package/dist/tools/accounts-tool.js +34 -18
  73. package/dist/tools/admin.d.ts +10 -1
  74. package/dist/tools/admin.js +15 -11
  75. package/dist/tools/analytics.d.ts +10 -1
  76. package/dist/tools/analytics.js +13 -8
  77. package/dist/tools/calendar.d.ts +2 -1
  78. package/dist/tools/calendar.js +18 -14
  79. package/dist/tools/chat.d.ts +10 -1
  80. package/dist/tools/chat.js +13 -8
  81. package/dist/tools/contacts.d.ts +2 -1
  82. package/dist/tools/contacts.js +17 -13
  83. package/dist/tools/docs.d.ts +2 -1
  84. package/dist/tools/docs.js +34 -30
  85. package/dist/tools/drive.d.ts +2 -1
  86. package/dist/tools/drive.js +65 -50
  87. package/dist/tools/forms.d.ts +10 -1
  88. package/dist/tools/forms.js +19 -12
  89. package/dist/tools/generated/_shared.d.ts +2 -1
  90. package/dist/tools/generated/_shared.js +7 -3
  91. package/dist/tools/generated/admin.d.ts +2 -1
  92. package/dist/tools/generated/admin.js +265 -265
  93. package/dist/tools/generated/analytics.d.ts +2 -1
  94. package/dist/tools/generated/analytics.js +125 -125
  95. package/dist/tools/generated/appsmarket.d.ts +2 -1
  96. package/dist/tools/generated/appsmarket.js +5 -5
  97. package/dist/tools/generated/calendar.d.ts +2 -1
  98. package/dist/tools/generated/calendar.js +55 -55
  99. package/dist/tools/generated/chat.d.ts +2 -1
  100. package/dist/tools/generated/chat.js +95 -95
  101. package/dist/tools/generated/classroom.d.ts +2 -1
  102. package/dist/tools/generated/classroom.js +209 -209
  103. package/dist/tools/generated/cloudidentity.d.ts +2 -1
  104. package/dist/tools/generated/cloudidentity.js +133 -133
  105. package/dist/tools/generated/cloudsearch.d.ts +2 -1
  106. package/dist/tools/generated/cloudsearch.js +99 -99
  107. package/dist/tools/generated/contacts.d.ts +2 -1
  108. package/dist/tools/generated/contacts.js +29 -29
  109. package/dist/tools/generated/drive.d.ts +2 -1
  110. package/dist/tools/generated/drive.js +71 -71
  111. package/dist/tools/generated/driveactivity.d.ts +2 -1
  112. package/dist/tools/generated/driveactivity.js +3 -3
  113. package/dist/tools/generated/drivelabels.d.ts +2 -1
  114. package/dist/tools/generated/drivelabels.js +53 -53
  115. package/dist/tools/generated/forms.d.ts +2 -1
  116. package/dist/tools/generated/forms.js +7 -7
  117. package/dist/tools/generated/gmail.d.ts +2 -1
  118. package/dist/tools/generated/gmail.js +117 -117
  119. package/dist/tools/generated/groupsmigration.d.ts +2 -1
  120. package/dist/tools/generated/groupsmigration.js +3 -3
  121. package/dist/tools/generated/groupssettings.d.ts +2 -1
  122. package/dist/tools/generated/groupssettings.js +7 -7
  123. package/dist/tools/generated/index.d.ts +2 -1
  124. package/dist/tools/generated/keep.d.ts +2 -1
  125. package/dist/tools/generated/keep.js +15 -15
  126. package/dist/tools/generated/licensing.d.ts +2 -1
  127. package/dist/tools/generated/licensing.js +15 -15
  128. package/dist/tools/generated/meet.d.ts +2 -1
  129. package/dist/tools/generated/meet.js +27 -27
  130. package/dist/tools/generated/postmaster.d.ts +2 -1
  131. package/dist/tools/generated/postmaster.js +9 -9
  132. package/dist/tools/generated/reseller.d.ts +2 -1
  133. package/dist/tools/generated/reseller.js +35 -35
  134. package/dist/tools/generated/script.d.ts +2 -1
  135. package/dist/tools/generated/script.js +33 -33
  136. package/dist/tools/generated/searchconsole.d.ts +2 -1
  137. package/dist/tools/generated/searchconsole.js +3 -3
  138. package/dist/tools/generated/sheets.d.ts +2 -1
  139. package/dist/tools/generated/sheets.js +15 -15
  140. package/dist/tools/generated/tasks.d.ts +2 -1
  141. package/dist/tools/generated/tasks.js +5 -5
  142. package/dist/tools/generated/vault.d.ts +2 -1
  143. package/dist/tools/generated/vault.js +67 -67
  144. package/dist/tools/generated/workspaceevents.d.ts +2 -1
  145. package/dist/tools/generated/workspaceevents.js +31 -31
  146. package/dist/tools/gmail.d.ts +2 -1
  147. package/dist/tools/gmail.js +45 -33
  148. package/dist/tools/google-api.d.ts +2 -3
  149. package/dist/tools/google-api.js +30 -16
  150. package/dist/tools/meet.d.ts +10 -1
  151. package/dist/tools/meet.js +13 -9
  152. package/dist/tools/searchconsole.d.ts +2 -1
  153. package/dist/tools/searchconsole.js +17 -13
  154. package/dist/tools/sheets.d.ts +2 -1
  155. package/dist/tools/sheets.js +36 -32
  156. package/dist/tools/slides.d.ts +10 -1
  157. package/dist/tools/slides.js +17 -10
  158. package/dist/tools/tasks.d.ts +2 -1
  159. package/dist/tools/tasks.js +19 -15
  160. package/dist/trim.d.ts +5 -0
  161. package/dist/trim.js +20 -0
  162. package/dist/usage-metrics.d.ts +23 -0
  163. package/dist/usage-metrics.js +65 -47
  164. package/dist/write-control.js +10 -2
  165. package/package.json +27 -1
@@ -1,5 +1,6 @@
1
1
  import { reauthHint } from '../reauth-hint.js';
2
2
  import { sliceClean, sliceEncoded } from '../trim.js';
3
+ import { hints as hintProvider } from '../hint-provider.js';
3
4
  const MAX_ERROR_MESSAGE_CHARS = 1000;
4
5
  const MAX_ERROR_ENVELOPE_CHARS = 4000;
5
6
  const MAX_BODY_PARSE_CHARS = 64_000;
@@ -175,10 +176,11 @@ function rateLimited(error, message, account, reason) {
175
176
  };
176
177
  }
177
178
  if (reason === 'dailyLimitExceeded') {
179
+ const rendered = hintProvider()?.render({ kind: 'daily_quota' });
178
180
  return {
179
181
  error: 'rate_limited',
180
182
  message,
181
- hint: 'A per-day project quota, not a burst limit: it resets at midnight Pacific. Reduce call volume or raise the quota in the Cloud Console; a tight retry loop will not clear it.',
183
+ hint: rendered?.hint ?? 'A per-day project quota, not a burst limit: it resets at midnight Pacific. Reduce call volume or raise the quota in the Cloud Console; a tight retry loop will not clear it.',
182
184
  retriable: true,
183
185
  account,
184
186
  };
@@ -222,21 +224,24 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
222
224
  .filter(Boolean)
223
225
  .join(' ');
224
226
  if ((status === 400 || status === 401) && /invalid_grant/i.test(grantHaystack)) {
227
+ const rendered = hintProvider()?.render({ kind: 'dead_refresh_token', account });
225
228
  return {
226
229
  error: 'reauth_required',
227
230
  message,
228
- hint: `Refresh token for "${account}" is dead (often the 7-day Testing-mode trap). ` +
229
- 'Set Publishing status to In production at https://console.cloud.google.com/auth/audience, ' +
230
- `then ${reauthHint(account)}`,
231
+ hint: rendered?.hint ??
232
+ (`Refresh token for "${account}" is dead (often the 7-day Testing-mode trap). ` +
233
+ 'Set Publishing status to In production at https://console.cloud.google.com/auth/audience, ' +
234
+ `then ${reauthHint(account)}`),
231
235
  retriable: false,
232
236
  account,
233
237
  };
234
238
  }
235
239
  if (status === 401) {
240
+ const rendered = hintProvider()?.render({ kind: 'reauth', account });
236
241
  return {
237
242
  error: 'auth_required',
238
243
  message: `Authentication failed for account "${account}".`,
239
- hint: reauthHint(account),
244
+ hint: rendered?.hint ?? reauthHint(account),
240
245
  retriable: false,
241
246
  account,
242
247
  };
@@ -252,7 +257,14 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
252
257
  reason === 'SERVICE_DISABLED' ||
253
258
  /has not been used in project|accessNotConfigured|SERVICE_DISABLED|it is disabled/i.test(message);
254
259
  if (notEnabled) {
255
- return { error: 'api_not_enabled', message, hint: enableHint(info, message), retriable: false, account };
260
+ const rendered = hintProvider()?.render({
261
+ kind: 'api_not_enabled',
262
+ api: disabledApiId(message) ?? undefined,
263
+ service: info?.metadata?.service,
264
+ project: info?.metadata?.consumer?.replace(/^projects\//, ''),
265
+ googleMessage: message,
266
+ });
267
+ return { error: 'api_not_enabled', message, hint: rendered?.hint ?? enableHint(info, message), retriable: false, account };
256
268
  }
257
269
  // The scope hint fires ONLY on the machine-readable token. A coarse AIP
258
270
  // status of PERMISSION_DENIED is equally true of a resource denial, and
@@ -262,10 +274,13 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
262
274
  /\bACCESS_TOKEN_SCOPE_INSUFFICIENT\b/.test(message);
263
275
  if (scopeIssue) {
264
276
  const enriched = scopeContext?.() ?? null;
277
+ const fallback = enriched
278
+ ? null
279
+ : hintProvider()?.render({ kind: 'scope_unknown', account, serviceText: hints.scope, bundle: hints.bundle });
265
280
  return {
266
281
  error: 'insufficient_scope',
267
282
  message,
268
- hint: enriched?.hint ?? hints.scope ?? `Re-auth "${account}" with the scope this operation needs. ${reauthHint(account)}`,
283
+ hint: enriched?.hint ?? fallback?.hint ?? hints.scope ?? `Re-auth "${account}" with the scope this operation needs. ${reauthHint(account)}`,
269
284
  retriable: enriched?.retriable ?? false,
270
285
  account,
271
286
  };
@@ -274,7 +289,8 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
274
289
  return { error: 'forbidden', message, hint: `Drive storage for "${account}" is full. Free space or use another account; retrying will not help.`, retriable: false, account };
275
290
  }
276
291
  if (reason === 'CONSUMER_SUSPENDED' || reason === 'accountSuspended') {
277
- return { error: 'forbidden', message, hint: `Google has suspended the project or the account behind "${account}". Check the Cloud Console notifications; no change to the request will fix this.`, retriable: false, account };
292
+ const rendered = hintProvider()?.render({ kind: 'project_suspended', account });
293
+ return { error: 'forbidden', message, hint: rendered?.hint ?? `Google has suspended the project or the account behind "${account}". Check the Cloud Console notifications; no change to the request will fix this.`, retriable: false, account };
278
294
  }
279
295
  if (reason === 'domainPolicy' || reason === 'ORG_RESTRICTION_VIOLATION') {
280
296
  return { error: 'forbidden', message, hint: `A Workspace admin policy blocks this operation for the domain of "${account}". A super-admin has to allow the app or the feature; re-auth will not change it.`, retriable: false, account };
@@ -289,10 +305,11 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
289
305
  };
290
306
  }
291
307
  if (status === 400 && /invalid[_ ]scope/i.test(message)) {
308
+ const rendered = hintProvider()?.render({ kind: 'invalid_scope', account });
292
309
  return {
293
310
  error: 'invalid_scope',
294
311
  message,
295
- hint: 'One of the requested OAuth scopes is malformed or unavailable to this client. Run `config check` to review the account scope profile, fix it, then re-auth.',
312
+ hint: rendered?.hint ?? 'One of the requested OAuth scopes is malformed or unavailable to this client. Run `config check` to review the account scope profile, fix it, then re-auth.',
296
313
  retriable: false,
297
314
  account,
298
315
  };
@@ -335,13 +352,15 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
335
352
  // The server's own preconditions, tagged at the throw site in client.ts.
336
353
  // Untagged they reached the floor and were reported as Google failures.
337
354
  if (error?.code === 'E_NO_TOKEN') {
338
- return { error: 'auth_required', message, hint: reauthHint(account), retriable: false, account };
355
+ const rendered = hintProvider()?.render({ kind: 'reauth', account });
356
+ return { error: 'auth_required', message, hint: rendered?.hint ?? reauthHint(account), retriable: false, account };
339
357
  }
340
358
  if (error?.code === 'E_NO_OAUTH_CLIENT') {
359
+ const rendered = hintProvider()?.render({ kind: 'no_oauth_client' });
341
360
  return {
342
361
  error: 'invalid_client',
343
362
  message,
344
- hint: 'Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET (see docs/google-cloud-setup.md), then restart the server.',
363
+ hint: rendered?.hint ?? 'Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET (see docs/google-cloud-setup.md), then restart the server.',
345
364
  retriable: false,
346
365
  account,
347
366
  };
@@ -357,23 +376,30 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
357
376
  }
358
377
  const fsCode = typeof error?.code === 'string' && LOCAL_FS_CODES.has(error.code) ? error.code : undefined;
359
378
  if (fsCode) {
360
- const p = typeof error?.path === 'string' ? ` "${error.path}"` : '';
379
+ const p = typeof error?.path === 'string' ? error.path : undefined;
380
+ const rendered = hintProvider()?.render({ kind: 'local_path', code: fsCode, path: p });
361
381
  return {
362
382
  error: 'invalid_params',
363
- message: `Cannot access local path${p}: ${fsCode}`,
364
- hint: 'The path must exist on the machine running this server and be accessible to it. ' +
365
- 'When the server runs remotely (HTTP transport), paths on your own machine are not visible to it.',
383
+ message: rendered?.message ?? `Cannot access local path${p ? ` "${p}"` : ''}: ${fsCode}`,
384
+ hint: rendered?.hint ??
385
+ ('The path must exist on the machine running this server and be accessible to it. ' +
386
+ 'When the server runs remotely (HTTP transport), paths on your own machine are not visible to it.'),
366
387
  retriable: false,
367
388
  account,
368
389
  };
369
390
  }
370
391
  const netCode = netCodeOf(error);
371
392
  if (netCode) {
393
+ const rendered = hintProvider()?.render({ kind: 'network_error', code: netCode });
372
394
  return {
373
395
  error: 'network_error',
374
396
  message: message.includes(netCode) ? message : message.endsWith('reason: ') ? `${message}${netCode}` : `${message} (${netCode})`,
375
- hint: `Network failure (${netCode}) before reaching Google - not an auth or API problem. Usually transient: retry. ` +
376
- '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.',
397
+ hint: rendered?.hint ??
398
+ (`Network failure (${netCode}) before reaching Google - not an auth or API problem. Usually transient: retry. ` +
399
+ '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.
377
403
  retriable: RETRIABLE_NET_CODES.has(netCode),
378
404
  account,
379
405
  };
@@ -392,13 +418,16 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
392
418
  account,
393
419
  };
394
420
  }
395
- return {
396
- error: 'internal',
397
- message,
398
- hint: 'This failed inside the MCP server, before any request was sent. The message above is the signal: it is most often a configuration problem such as a missing key or credential. Retrying the same call will fail the same way.',
399
- retriable: false,
400
- account,
401
- };
421
+ {
422
+ const rendered = hintProvider()?.render({ kind: 'internal', account });
423
+ return {
424
+ error: 'internal',
425
+ message: rendered?.message ?? message,
426
+ hint: rendered?.hint ?? 'This failed inside the MCP server, before any request was sent. The message above is the signal: it is most often a configuration problem such as a missing key or credential. Retrying the same call will fail the same way.',
427
+ retriable: false,
428
+ account,
429
+ };
430
+ }
402
431
  }
403
432
  /** Bound the SERIALIZED envelope, not just the message: an authored hint can
404
433
  * be long too, and the error path had no cap at all before this. */
@@ -1,3 +1,12 @@
1
1
  import * as fs from 'fs';
2
+ /** Refusal for a host-file argument on a context that may not touch the
3
+ * server's disk: the host belongs to its operator, not to every caller. */
4
+ export declare function hostFilesRefused(account: string | undefined, what: string): {
5
+ content: {
6
+ type: "text";
7
+ text: string;
8
+ }[];
9
+ isError: true;
10
+ };
2
11
  export declare function prepareLocalDest(savePath: string, filename: string): string;
3
12
  export declare function openLocalReadStream(localPath: string): Promise<fs.ReadStream>;
@@ -1,5 +1,23 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
+ import { stringifyEnvelope } from './_errors.js';
4
+ /** Refusal for a host-file argument on a context that may not touch the
5
+ * server's disk: the host belongs to its operator, not to every caller. */
6
+ export function hostFilesRefused(account, what) {
7
+ return {
8
+ content: [{
9
+ type: 'text',
10
+ text: stringifyEnvelope({
11
+ error: 'forbidden',
12
+ message: `${what}: this caller may not read files on the machine running the server.`,
13
+ hint: 'Put the file in Drive first and work from its Drive id.',
14
+ retriable: false,
15
+ account,
16
+ }),
17
+ }],
18
+ isError: true,
19
+ };
20
+ }
3
21
  // path.basename() is a traversal guard — a caller-supplied filename must never escape savePath.
4
22
  export function prepareLocalDest(savePath, filename) {
5
23
  const name = path.basename(filename);
@@ -53,4 +53,13 @@ export declare function argsToAddForm(a: {
53
53
  /** Scopes requested by the profile but NOT granted at consent (granular
54
54
  * consent / unchecked bundles). Pure. */
55
55
  export declare function scopeGrantDiff(requested: string[], grantedScope: string | undefined): string[];
56
- export declare function registerAccountWizardTools(registry: ToolRegistry, server: McpServer): void;
56
+ /** HTTP-transport consent context (S1.20): set by the HTTP bootstrap. When
57
+ * present, runConsent NEVER binds a server-side loopback listener — the
58
+ * user's browser cannot reach the daemon's loopback — and instead hands out
59
+ * a clientless AS consent URL. The injector decides the flow (single-owner =
60
+ * the legacy alias_reauth link). */
61
+ export interface WizardHttpConsent {
62
+ mintConsentUrl: (alias: string) => Promise<string> | string;
63
+ }
64
+ export declare function setWizardHttpConsent(ctx: WizardHttpConsent | null): void;
65
+ export declare function registerAccountWizardTools(registry: ToolRegistry, server: McpServer, awaitingRestart?: () => string[]): void;
@@ -1,21 +1,14 @@
1
1
  import { z } from 'zod';
2
- import { getAccountSet, invalidateAccountSet } from '../accounts.js';
3
- import { mutateConfigFile } from '../config-file.js';
2
+ import { getAccountSet, invalidateAccountSet, refreshAccountSetIfStale } from '../accounts.js';
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, resolveBundleAliases } from '../scope-catalog.js';
6
+ import { BUNDLE_CATALOG, closestBundle, isKnownBundle, 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
- // B7: the elicitation-driven account_add / account_reauth wizard. It rebuilds
13
- // interactive account management on the mutable config.json registry so a
14
- // running server can add an account without the deployer editing env + running
15
- // a CLI. Google consent reuses the loopback flow (leg C); the HTTP-transport
16
- // consent path (${BASE}/authorize) is owned by the OAuth AS and lands with that
17
- // cluster. account_add over http is gated behind it.
18
- const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
19
12
  // The common optional bundles get their own checkbox (MCP elicitation has no
20
13
  // multi-select, so a boolean per bundle IS the checklist); the long tail stays
21
14
  // a comma-separated field for power users, and `allBundles` grants every one.
@@ -65,7 +58,7 @@ export function validateAddForm(input, existingAliases) {
65
58
  if (b === 'admin') {
66
59
  return { ok: false, slug: 'E_UNKNOWN_BUNDLE', message: '"admin" is not a scope bundle.', hint: 'Use the admin checkbox instead, or pass "admin": true.', alias };
67
60
  }
68
- if (!(b in BUNDLE_CATALOG)) {
61
+ if (!isKnownBundle(b)) {
69
62
  const closest = closestBundle(b);
70
63
  const known = Object.keys(BUNDLE_CATALOG).filter((n) => n !== 'admin').join(', ');
71
64
  return {
@@ -143,6 +136,25 @@ function errorResult(slug, message, hint, account) {
143
136
  isError: true,
144
137
  };
145
138
  }
139
+ let httpConsent = null;
140
+ export function setWizardHttpConsent(ctx) {
141
+ httpConsent = ctx;
142
+ }
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. */
146
+ function restartNote(pending) {
147
+ if (pending.length === 0)
148
+ return '';
149
+ const names = pending.map((s) => `"${s}"`).join(', ');
150
+ const what = pending.length > 1 ? `services ${names} register their` : `service ${names} registers its`;
151
+ return `\nThe ${what} tools when the server starts: restart the server to use them. Until then, google_api_call can reach the same API.`;
152
+ }
153
+ function pendingText(alias, url, pending) {
154
+ return (`Account "${alias}" is ready to authorize. Open this link in a browser (it belongs to whoever owns the Google account):\n${url}\n` +
155
+ `On completion the token is stored server-side and the account is usable immediately — no restart. ` +
156
+ `If the link expires, run account_reauth for a fresh one.${restartNote(pending)}\n${TESTING_MODE_WARNING}`);
157
+ }
146
158
  /** Run Google consent for `alias` and persist the token. Opens the browser via
147
159
  * URL-mode elicitation when the client supports it, else server-side + prints
148
160
  * the URL. Returns the granted-scope diff for the S4 report. */
@@ -157,6 +169,29 @@ async function runConsent(server, alias) {
157
169
  hint: 'Accounts sourced from GOOGLE_ACCOUNTS are not editable here. Run `mcp-google-multi migrate-config` to move them into config.json.',
158
170
  };
159
171
  }
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).
175
+ const http = httpConsent;
176
+ if (http) {
177
+ let url;
178
+ try {
179
+ url = await http.mintConsentUrl(alias);
180
+ }
181
+ catch (e) {
182
+ return { ok: false, slug: 'internal', message: safeMessage(e) };
183
+ }
184
+ const caps = server.server.getClientCapabilities?.();
185
+ if (caps?.elicitation?.url !== undefined) {
186
+ try {
187
+ await server.server.elicitInput(urlElicitationParams(alias, url, randomBytes(16).toString('hex')));
188
+ }
189
+ catch {
190
+ // the URL in the pending text is the recovery
191
+ }
192
+ }
193
+ return { ok: 'pending', url };
194
+ }
160
195
  // Bind the ephemeral loopback listener BEFORE building the auth URL: the
161
196
  // redirect URI needs the assigned port, and listening first means the
162
197
  // callback can't race the browser.
@@ -211,14 +246,14 @@ async function runConsent(server, alias) {
211
246
  writeToken(alias, tokens);
212
247
  return { ok: true, missing: scopeGrantDiff(scopes, typeof tokens.scope === 'string' ? tokens.scope : undefined) };
213
248
  }
214
- function s4Text(alias, missing) {
249
+ function s4Text(alias, missing, pending) {
215
250
  const outcome = missing.length === 0
216
251
  ? `✔ "${alias}" authenticated; all requested scopes granted. It is now usable without a restart.`
217
252
  : `⚠ "${alias}" authenticated, but ${missing.length} requested scope(s) were NOT granted (E_SCOPE_NOT_GRANTED). You may have unchecked some on the consent screen. Re-run account_reauth to grant them. The account is usable for the granted scopes.`;
218
- return `${outcome}\n${TESTING_MODE_WARNING}`;
253
+ return `${outcome}${restartNote(pending)}\n${TESTING_MODE_WARNING}`;
219
254
  }
220
255
  const REQUIRES_INTERACTION = { 'anthropic/requiresUserInteraction': true };
221
- export function registerAccountWizardTools(registry, server) {
256
+ export function registerAccountWizardTools(registry, server, awaitingRestart = () => []) {
222
257
  // Registered as META (always-visible, like account_list) so onboarding tools
223
258
  // are reachable in lazy mode without discover_all first; registerMeta still
224
259
  // preserves the requiresUserInteraction clientMeta and skips the fan-out path.
@@ -226,7 +261,7 @@ export function registerAccountWizardTools(registry, server) {
226
261
  registerMeta('account_add', {
227
262
  _meta: REQUIRES_INTERACTION,
228
263
  annotations: { openWorldHint: true },
229
- description: 'Add a new Google account: pass alias + email directly (plus optional bundles/allBundles/admin), or pass nothing for an interactive form where the client supports elicitation. Writes the registry and runs Google consent in the browser. No file editing or restart needed. Requires GOOGLE_CLIENT_ID/SECRET (run the `setup` prompt first if missing).',
264
+ description: 'Add a new Google account: pass alias + email directly (plus optional bundles/allBundles/admin), or pass nothing for an interactive form where the client supports elicitation. Writes the registry and runs Google consent in the browser. No file editing or restart needed to use the account (tools of a service it newly enables register at the next restart). Requires GOOGLE_CLIENT_ID/SECRET (run the `setup` prompt first if missing).',
230
265
  inputSchema: {
231
266
  alias: z.string().optional().describe('Account alias (letters, digits, _ or -). Pass with email to add directly, skipping the form.'),
232
267
  account: z.string().optional().describe('Alias for the new account (same as `alias`; every other tool spells it `account`)'),
@@ -272,12 +307,23 @@ export function registerAccountWizardTools(registry, server) {
272
307
  // S2: atomic write + make the alias callable without a restart (BR3).
273
308
  writeAccountRow(validated.alias, validated.email, validated.bundles, validated.admin);
274
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.
313
+ try {
314
+ server.sendToolListChanged();
315
+ }
316
+ catch {
317
+ // a client that cannot receive notifications loses nothing but the nudge
318
+ }
275
319
  // S3 + S4: consent + validate.
276
320
  const consent = await runConsent(server, validated.alias);
321
+ if (consent.ok === 'pending')
322
+ return textResult(pendingText(validated.alias, consent.url, awaitingRestart()));
277
323
  if (!consent.ok)
278
324
  return errorResult(consent.slug, consent.message, consent.hint, validated.alias);
279
325
  // B15: offer to register the server with another MCP client.
280
- return textResult(`${s4Text(validated.alias, consent.missing)}\nTip: run account_write_config to register this server with another MCP client (Claude Desktop / Cursor / Claude Code).`);
326
+ 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).`);
281
327
  }
282
328
  catch (e) {
283
329
  // safeMessage, not error.message: an arbitrary throw here can carry a
@@ -303,6 +349,9 @@ export function registerAccountWizardTools(registry, server) {
303
349
  try {
304
350
  const a = args;
305
351
  const alias = a.alias ?? a.account ?? '';
352
+ // No default-account wrapper runs for this tool, so pick up a
353
+ // hand-edited config.json here: that edit is why a re-auth is asked for.
354
+ refreshAccountSetIfStale();
306
355
  if (!hasClientCredentials()) {
307
356
  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.');
308
357
  }
@@ -310,9 +359,11 @@ export function registerAccountWizardTools(registry, server) {
310
359
  return errorResult('validation_error', `Unknown account "${alias}".`, `Pass one of the configured aliases: ${getAccountSet().aliases.join(', ')}. Use account_add to create a new one.`, alias);
311
360
  }
312
361
  const consent = await runConsent(server, alias);
362
+ if (consent.ok === 'pending')
363
+ return textResult(pendingText(alias, consent.url, awaitingRestart()));
313
364
  if (!consent.ok)
314
365
  return errorResult(consent.slug, consent.message, consent.hint, alias);
315
- return textResult(s4Text(alias, consent.missing));
366
+ return textResult(s4Text(alias, consent.missing, awaitingRestart()));
316
367
  }
317
368
  catch (e) {
318
369
  return errorResult('internal', `account_reauth failed: ${safeMessage(e)}`);
@@ -360,6 +411,12 @@ export function registerAccountWizardTools(registry, server) {
360
411
  if (client.managed === 'cli') {
361
412
  blocks.push(`${client.label}: run\n ${instr.text}`);
362
413
  }
414
+ 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.
418
+ 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
+ }
363
420
  else if (a.write) {
364
421
  const res = applyFileEntry(client, name, entry);
365
422
  blocks.push(res.ok
@@ -1,5 +1,7 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
+ import { type AccountSet } from '../accounts.js';
2
3
  import { type ScopesReport } from '../scope-observability.js';
4
+ import { type TokenStore } from '../token-store.js';
3
5
  export interface AccountHealthDeps {
4
6
  hasToken: (alias: string) => boolean;
5
7
  readToken: (alias: string) => {
@@ -13,7 +15,12 @@ export interface AccountHealthDeps {
13
15
  * "registered but not requestable" view (B9). account_list stays compact:
14
16
  * its report universe is profile ∪ granted only. */
15
17
  registeredScopes?: () => string[];
18
+ /** Account view health is derived against; absent = the global registry. */
19
+ accounts?: () => AccountSet;
16
20
  }
21
+ /** Health deps over one context's token store (and, optionally, its account
22
+ * view); the module-level functions are the single owner's store. */
23
+ export declare function accountHealthDepsFor(store: Pick<TokenStore, 'hasToken' | 'readToken'>, accounts?: () => AccountSet): AccountHealthDeps;
17
24
  export type TokenStatus = 'ok' | 'expired_refreshable' | 'needs_reauth' | 'missing' | 'decrypt_error';
18
25
  export interface AccountHealth {
19
26
  alias: string;
@@ -1,18 +1,27 @@
1
+ import { reauthHint } from '../reauth-hint.js';
1
2
  import * as fs from 'node:fs';
2
3
  import { getAccountSet, refreshAccountSetIfStale } from '../accounts.js';
3
4
  import { buildScopesReport } from '../scope-observability.js';
4
5
  import { getAdminAccounts, resolveScopesForAccount } from '../auth.js';
5
6
  import { hasToken, readToken } from '../token-store.js';
6
- const DEFAULT_DEPS = {
7
- hasToken,
8
- readToken,
9
- fileExists: fs.existsSync,
10
- now: Date.now,
11
- };
7
+ import { hints } from '../hint-provider.js';
8
+ /** Health deps over one context's token store (and, optionally, its account
9
+ * view); the module-level functions are the single owner's store. */
10
+ export function accountHealthDepsFor(store, accounts) {
11
+ return {
12
+ hasToken: store.hasToken,
13
+ readToken: store.readToken,
14
+ fileExists: fs.existsSync,
15
+ now: Date.now,
16
+ ...(accounts ? { accounts } : {}),
17
+ };
18
+ }
19
+ const DEFAULT_DEPS = accountHealthDepsFor({ hasToken, readToken });
12
20
  export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
13
- const config = getAccountSet().configs[alias];
14
- const admin = getAdminAccounts().includes(alias);
15
- const configured = resolveScopesForAccount(alias);
21
+ const set = (deps.accounts ?? getAccountSet)();
22
+ const config = set.configs[alias];
23
+ const admin = getAdminAccounts(set).includes(alias);
24
+ const configured = resolveScopesForAccount(alias, set);
16
25
  // BR-10: env-sourced accounts are not persisted in config.json, so the
17
26
  // account wizard cannot edit them — the deployer edits env instead.
18
27
  const base = {
@@ -29,13 +38,17 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
29
38
  const noScopes = buildScopesReport(new Set(), profileSet, registered);
30
39
  if (!deps.hasToken(alias)) {
31
40
  const legacy = deps.fileExists(config.tokenPath);
41
+ const rendered = legacy
42
+ ? hints()?.render({ kind: 'legacy_plaintext_token', account: alias })
43
+ : hints()?.render({ kind: 'reauth', account: alias });
32
44
  return {
33
45
  ...base,
34
46
  token: {
35
47
  status: 'missing',
36
- hint: legacy
37
- ? 'Plaintext token.json found — run: npx mcp-google-multi migrate-tokens'
38
- : `Run: npx mcp-google-multi auth --account ${alias}`,
48
+ hint: rendered?.hint ??
49
+ (legacy
50
+ ? 'Plaintext token.json found — run: npx mcp-google-multi migrate-tokens'
51
+ : reauthHint(alias)),
39
52
  },
40
53
  scopes: noScopes,
41
54
  };
@@ -45,9 +58,10 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
45
58
  token = deps.readToken(alias);
46
59
  }
47
60
  catch {
61
+ const rendered = hints()?.render({ kind: 'token_unreadable', account: alias });
48
62
  return {
49
63
  ...base,
50
- token: { status: 'decrypt_error', hint: 'Wrong MASTER_KEY or corrupt token file — re-auth or restore the key.' },
64
+ token: { status: 'decrypt_error', hint: rendered?.hint ?? 'Wrong MASTER_KEY or corrupt token file — re-auth or restore the key.' },
51
65
  scopes: noScopes,
52
66
  };
53
67
  }
@@ -69,9 +83,9 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
69
83
  status,
70
84
  expiryDate: expiry !== undefined ? new Date(expiry).toISOString() : undefined,
71
85
  hint: status === 'needs_reauth'
72
- ? `Run: npx mcp-google-multi auth --account ${alias}`
86
+ ? (hints()?.render({ kind: 'reauth', account: alias })?.hint ?? reauthHint(alias))
73
87
  : missing.length > 0
74
- ? 'Re-auth to grant the missing scopes'
88
+ ? (hints()?.render({ kind: 'scopes_missing', account: alias })?.hint ?? 'Re-auth to grant the missing scopes')
75
89
  : undefined,
76
90
  },
77
91
  scopes: report,
@@ -87,14 +101,16 @@ export function registerAccountTools(registry, deps = DEFAULT_DEPS) {
87
101
  _meta: { 'anthropic/alwaysLoad': true },
88
102
  }, async () => {
89
103
  refreshAccountSetIfStale();
104
+ const set = registry.accountSet();
105
+ const health = { ...deps, accounts: () => set };
90
106
  return {
91
107
  content: [
92
108
  {
93
109
  type: 'text',
94
110
  text: JSON.stringify({
95
- defaultAccount: getAccountSet().defaultAccount ?? null,
96
- defaultAccountSource: getAccountSet().defaultAccountSource ?? null,
97
- accounts: getAccountSet().aliases.map((alias) => deriveAccountHealth(alias, deps)),
111
+ defaultAccount: set.defaultAccount ?? null,
112
+ defaultAccountSource: set.defaultAccountSource ?? null,
113
+ accounts: set.aliases.map((alias) => deriveAccountHealth(alias, health)),
98
114
  }),
99
115
  },
100
116
  ],
@@ -1,2 +1,11 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
- export declare function registerAdminTools(server: ToolRegistry): void;
2
+ import type { Account } from '../accounts.js';
3
+ import { type CuratedToolDeps } from '../client.js';
4
+ export declare function registerAdminTools(server: ToolRegistry, deps?: CuratedToolDeps): void;
5
+ export declare function handleAdminError(error: any, account: Account): {
6
+ content: {
7
+ type: "text";
8
+ text: string;
9
+ }[];
10
+ isError: true;
11
+ };
@@ -1,12 +1,16 @@
1
1
  import { z } from 'zod';
2
2
  import { coerceBoolean } from './_coerce.js';
3
3
  import { admin as adminClient } from '@googleapis/admin';
4
- import { accountAliasSchema } from '../accounts.js';
4
+ import { accountArgLive } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError, invalidParams } from './_errors.js';
7
- const accountEnum = accountAliasSchema.optional();
8
7
  // Admin SDK requires Workspace super-admin (or delegated admin) on the account — personal @gmail.com accounts 403 on every endpoint.
9
- export function registerAdminTools(server) {
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.
12
+ const accountEnum = accountArgLive(() => server.accountAliases()).optional();
13
+ const getClientFn = deps.getClientFn ?? getClient;
10
14
  // ─── Reports / audit log ───────────────────────────────────────────────
11
15
  server.registerTool('reports_activities_list', {
12
16
  description: 'List Admin Activity audit log entries. Filter by application (login, drive, gmail, admin, token, etc.), user, date range, event. The single most useful admin endpoint for SMB visibility.',
@@ -34,7 +38,7 @@ export function registerAdminTools(server) {
34
38
  },
35
39
  }, async ({ account, applicationName, userKey, startTime, endTime, eventName, actorIpAddress, filters, orgUnitID, groupIdFilter, customerId, maxResults, pageToken }) => {
36
40
  try {
37
- const auth = await getClient(account);
41
+ const auth = await getClientFn(account);
38
42
  const reports = adminClient({ version: 'reports_v1', auth });
39
43
  const res = await reports.activities.list({
40
44
  applicationName,
@@ -74,7 +78,7 @@ export function registerAdminTools(server) {
74
78
  },
75
79
  }, async ({ account, customer, domain, query, maxResults, pageToken, orderBy, showDeleted, projection }) => {
76
80
  try {
77
- const auth = await getClient(account);
81
+ const auth = await getClientFn(account);
78
82
  const directory = adminClient({ version: 'directory_v1', auth });
79
83
  const res = await directory.users.list({
80
84
  customer: customer ?? 'my_customer',
@@ -103,7 +107,7 @@ export function registerAdminTools(server) {
103
107
  },
104
108
  }, async ({ account, userKey, projection }) => {
105
109
  try {
106
- const auth = await getClient(account);
110
+ const auth = await getClientFn(account);
107
111
  const directory = adminClient({ version: 'directory_v1', auth });
108
112
  const res = await directory.users.get({
109
113
  userKey,
@@ -131,7 +135,7 @@ export function registerAdminTools(server) {
131
135
  },
132
136
  }, async ({ account, userKey, givenName, familyName, suspended, password, changePasswordAtNextLogin, orgUnitPath }) => {
133
137
  try {
134
- const auth = await getClient(account);
138
+ const auth = await getClientFn(account);
135
139
  const directory = adminClient({ version: 'directory_v1', auth });
136
140
  const requestBody = {};
137
141
  if (givenName !== undefined || familyName !== undefined) {
@@ -175,7 +179,7 @@ export function registerAdminTools(server) {
175
179
  },
176
180
  }, async ({ account, customer, domain, userKey, query, maxResults, pageToken }) => {
177
181
  try {
178
- const auth = await getClient(account);
182
+ const auth = await getClientFn(account);
179
183
  const directory = adminClient({ version: 'directory_v1', auth });
180
184
  const res = await directory.groups.list({
181
185
  customer: customer ?? 'my_customer',
@@ -205,7 +209,7 @@ export function registerAdminTools(server) {
205
209
  },
206
210
  }, async ({ account, groupKey, roles, includeDerivedMembership, maxResults, pageToken }) => {
207
211
  try {
208
- const auth = await getClient(account);
212
+ const auth = await getClientFn(account);
209
213
  const directory = adminClient({ version: 'directory_v1', auth });
210
214
  const res = await directory.members.list({
211
215
  groupKey,
@@ -223,7 +227,7 @@ export function registerAdminTools(server) {
223
227
  }
224
228
  });
225
229
  }
226
- function handleAdminError(error, account) {
230
+ export function handleAdminError(error, account) {
227
231
  const admin = 'Admin tools require Workspace super-admin privileges AND the account must be listed as an admin account (then re-authenticated). Personal Gmail accounts cannot use these endpoints.';
228
- return handleGoogleApiError(error, account, { scope: admin, resource: admin });
232
+ return handleGoogleApiError(error, account, { scope: admin, resource: admin, bundle: 'admin' });
229
233
  }