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,4 +1,14 @@
1
1
  import { OAuth2Client } from 'googleapis-common';
2
+ /** Google authorize-URL options for the owner's HTTP authorization server.
3
+ * alias_reauth asks for the alias's scopes plus openid/email, which /callback
4
+ * needs to bind the returned identity to the alias. It carries no login_hint:
5
+ * the link works without authentication, so a hint would hand the alias's
6
+ * address to anyone who opens it. Every other flow is identity-only. */
7
+ export declare function ownerAuthUrlOptions(opts: {
8
+ flow: string;
9
+ alias?: string;
10
+ state: string;
11
+ }, scopesFor: (alias: string) => string[]): Parameters<OAuth2Client['generateAuthUrl']>[0];
2
12
  /** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
3
13
  export declare class ClientCredentialsMissingError extends Error {
4
14
  constructor();
@@ -7,6 +7,22 @@ import { URL } from 'node:url';
7
7
  // call forever. The listener binds an EPHEMERAL loopback port (RFC 8252 §7.3;
8
8
  // Google Desktop clients accept any http://localhost:<port> redirect), so a
9
9
  // second local process on a fixed port can never break auth.
10
+ /** Google authorize-URL options for the owner's HTTP authorization server.
11
+ * alias_reauth asks for the alias's scopes plus openid/email, which /callback
12
+ * needs to bind the returned identity to the alias. It carries no login_hint:
13
+ * the link works without authentication, so a hint would hand the alias's
14
+ * address to anyone who opens it. Every other flow is identity-only. */
15
+ export function ownerAuthUrlOptions(opts, scopesFor) {
16
+ if (opts.flow === 'alias_reauth' && opts.alias) {
17
+ return {
18
+ access_type: 'offline',
19
+ prompt: 'select_account consent',
20
+ scope: [...new Set([...scopesFor(opts.alias), 'openid', 'email'])],
21
+ state: opts.state,
22
+ };
23
+ }
24
+ return { scope: ['openid', 'email'], prompt: 'select_account', state: opts.state };
25
+ }
10
26
  /** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
11
27
  export class ClientCredentialsMissingError extends Error {
12
28
  constructor() {
@@ -3,6 +3,7 @@
3
3
  // or share outside the operator's list), a safety feature first and an
4
4
  // enterprise checkbox second. FREE-core forever (ee-definition-v0 handoff).
5
5
  // Off by default: an unset/empty GOOGLE_OUTBOUND_ALLOWLIST gates nothing.
6
+ import { hints } from './hint-provider.js';
6
7
  /** Parse GOOGLE_OUTBOUND_ALLOWLIST: comma-separated addresses and @domain
7
8
  * suffixes ("a@b.com, @company.com"). null = feature off (nothing gated). */
8
9
  export function resolveOutboundAllowlist(env = process.env) {
@@ -34,6 +35,7 @@ export function outboundViolations(emails, env = process.env) {
34
35
  /** The standard error envelope for a blocked outbound target. */
35
36
  export function outboundDeniedEnvelope(kind, blocked, account, env = process.env) {
36
37
  const list = resolveOutboundAllowlist(env);
38
+ const rendered = hints()?.render({ kind: 'outbound_denied', what: kind, blocked, entries: list?.entries ?? [] });
37
39
  return {
38
40
  content: [
39
41
  {
@@ -41,8 +43,9 @@ export function outboundDeniedEnvelope(kind, blocked, account, env = process.env
41
43
  text: JSON.stringify({
42
44
  error: 'recipient_not_allowed',
43
45
  message: `${kind} blocked by the outbound allowlist: ${blocked.join(', ')}.`,
44
- hint: `GOOGLE_OUTBOUND_ALLOWLIST is active (${(list?.entries ?? []).join(', ')}). ` +
45
- 'The operator must add the address (or its @domain) to the list, or unset the variable, to allow this target.',
46
+ hint: rendered?.hint ??
47
+ (`GOOGLE_OUTBOUND_ALLOWLIST is active (${(list?.entries ?? []).join(', ')}). ` +
48
+ 'The operator must add the address (or its @domain) to the list, or unset the variable, to allow this target.'),
46
49
  retriable: false,
47
50
  account,
48
51
  }),
@@ -1,2 +1,2 @@
1
- export declare function setHttpReauthBase(base: string | null): void;
1
+ export declare function setHttpReauthLink(linker: ((alias: string) => string) | null): void;
2
2
  export declare function reauthHint(account: string): string;
@@ -1,15 +1,19 @@
1
1
  // B13 in-call re-auth (BV-1): a dead/missing per-account Google token is a
2
2
  // leg-C failure. Over stdio the fix is the `auth --account` CLI; over HTTP the
3
- // user cannot run a CLI, so the hint becomes a clickable link into the AS's
4
- // clientless alias_reauth flow (${BASE}/authorize?flow=alias_reauth&alias=…).
5
- // The HTTP bootstrap sets the base when the AS starts; stdio never does.
6
- let httpReauthBase = null;
7
- export function setHttpReauthBase(base) {
8
- httpReauthBase = base;
3
+ // user cannot run a CLI, so the hint becomes a clickable, server-signed link
4
+ // into the AS's clientless alias_reauth flow (AuthServer.reauthLink). The HTTP
5
+ // bootstrap installs the linker when the AS starts; stdio never does.
6
+ import { hints } from './hint-provider.js';
7
+ let httpReauthLink = null;
8
+ export function setHttpReauthLink(linker) {
9
+ httpReauthLink = linker;
9
10
  }
10
11
  export function reauthHint(account) {
11
- if (httpReauthBase) {
12
- return `Re-authenticate "${account}" in your browser, then retry: ${httpReauthBase}/authorize?flow=alias_reauth&alias=${encodeURIComponent(account)}`;
12
+ const rendered = hints()?.render({ kind: 'reauth', account });
13
+ if (rendered)
14
+ return rendered.hint;
15
+ if (httpReauthLink) {
16
+ return `Re-authenticate "${account}" in your browser, then retry: ${httpReauthLink(account)}`;
13
17
  }
14
18
  return `Run: npx mcp-google-multi auth --account ${account}`;
15
19
  }
@@ -1,3 +1,8 @@
1
+ /** D11 (refuse-only for v1): export/import operate on the FLAT single-owner
2
+ * store. A tenant-namespaced layout means the bundle would either miss every
3
+ * tenant's tokens or exfiltrate all of them in one file — refuse instead.
4
+ * Returns the offending dir, or null when the store is flat. */
5
+ export declare function refuseIfMultiTenant(dir?: string): string | null;
1
6
  export interface TransferBundle {
2
7
  manifest: {
3
8
  v: 1;
@@ -7,11 +7,24 @@
7
7
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
8
8
  import * as readline from 'node:readline';
9
9
  import path from 'node:path';
10
- import { encryptToken, decryptToken } from './token-store.js';
10
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
11
11
  import { atomicWriteFileSync, atomicWriteWithLock } from './fs-atomic.js';
12
- import { configFilePath, CONFIG_VERSION } from './config-file.js';
12
+ import { ALIAS_RE, configFilePath, CONFIG_VERSION, tenantsDir } from './config-file.js';
13
13
  import { getTokenDir } from './accounts.js';
14
- const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
14
+ /** D11 (refuse-only for v1): export/import operate on the FLAT single-owner
15
+ * store. A tenant-namespaced layout means the bundle would either miss every
16
+ * tenant's tokens or exfiltrate all of them in one file — refuse instead.
17
+ * Returns the offending dir, or null when the store is flat. */
18
+ export function refuseIfMultiTenant(dir = tenantsDir()) {
19
+ try {
20
+ if (readdirSync(dir).length > 0)
21
+ return dir;
22
+ }
23
+ catch {
24
+ // absent or unreadable tenants/ = flat store
25
+ }
26
+ return null;
27
+ }
15
28
  function resolveDeps(deps) {
16
29
  return {
17
30
  configPath: deps.configPath ?? configFilePath(),
@@ -48,15 +61,52 @@ export function buildTransferBundle(exportedAt, deps = {}) {
48
61
  }
49
62
  return { manifest: { v: 1, exportedAt, aliases: [...aliasSet].sort() }, config, tokens };
50
63
  }
64
+ // Bundle envelope v2: a REAL passphrase KDF. The v1 envelope reused the
65
+ // token store's deriveKey, whose passphrase branch is one unsalted sha256 —
66
+ // an exported bundle could be brute-forced offline (CodeQL
67
+ // js/insufficient-password-hash). scrypt with a per-bundle random salt fixes
68
+ // that; v1 bundles (a prerelease-only format, never produced by any stable
69
+ // release) are refused with a re-export hint rather than kept decryptable.
70
+ const BUNDLE_VERSION = 2;
71
+ const SCRYPT_PARAMS = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
72
+ function bundleKey(passphrase, salt) {
73
+ return scryptSync(passphrase, salt, 32, SCRYPT_PARAMS);
74
+ }
51
75
  export function encryptBundle(bundle, passphrase) {
52
- return encryptToken(bundle, passphrase);
76
+ const salt = randomBytes(16);
77
+ const iv = randomBytes(12);
78
+ const cipher = createCipheriv('aes-256-gcm', bundleKey(passphrase, salt), iv);
79
+ const data = Buffer.concat([cipher.update(Buffer.from(JSON.stringify(bundle), 'utf8')), cipher.final()]);
80
+ const file = {
81
+ bv: BUNDLE_VERSION,
82
+ salt: salt.toString('base64'),
83
+ iv: iv.toString('base64'),
84
+ tag: cipher.getAuthTag().toString('base64'),
85
+ data: data.toString('base64'),
86
+ };
87
+ return JSON.stringify(file);
53
88
  }
54
89
  export class BundleDecryptError extends Error {
55
90
  }
56
91
  export function decryptBundle(contents, passphrase) {
92
+ let file;
93
+ try {
94
+ file = JSON.parse(contents);
95
+ }
96
+ catch {
97
+ throw new BundleDecryptError('The file is not a registry export bundle.');
98
+ }
99
+ if (file.v !== undefined && file.bv === undefined) {
100
+ throw new BundleDecryptError('This bundle uses the retired v1 prerelease format (weak passphrase derivation); re-export it from the source machine with the current version.');
101
+ }
102
+ if (file.bv !== BUNDLE_VERSION || !file.salt || !file.iv || !file.tag || !file.data) {
103
+ throw new BundleDecryptError('The file is not a valid registry export (bad envelope).');
104
+ }
57
105
  let obj;
58
106
  try {
59
- obj = decryptToken(contents, passphrase);
107
+ const decipher = createDecipheriv('aes-256-gcm', bundleKey(passphrase, Buffer.from(file.salt, 'base64')), Buffer.from(file.iv, 'base64'));
108
+ decipher.setAuthTag(Buffer.from(file.tag, 'base64'));
109
+ obj = JSON.parse(Buffer.concat([decipher.update(Buffer.from(file.data, 'base64')), decipher.final()]).toString('utf8'));
60
110
  }
61
111
  catch {
62
112
  throw new BundleDecryptError('Could not decrypt the bundle — wrong passphrase or the file is corrupt.');
@@ -153,6 +203,11 @@ async function resolvePassphrase(prompt) {
153
203
  }
154
204
  }
155
205
  export async function runExportCli(argv) {
206
+ const mtDir = refuseIfMultiTenant();
207
+ if (mtDir) {
208
+ console.error(`E_MULTI_TENANT_EXPORT_REFUSED: tenant-namespaced token stores exist under ${mtDir}; account export only operates on a flat single-owner store.`);
209
+ return 2;
210
+ }
156
211
  const out = argFlag(argv, '--out');
157
212
  if (!out) {
158
213
  console.error('Usage: mcp-google-multi account export --out <bundle.enc>');
@@ -170,6 +225,11 @@ export async function runExportCli(argv) {
170
225
  return 0;
171
226
  }
172
227
  export async function runImportCli(argv) {
228
+ const mtDir = refuseIfMultiTenant();
229
+ if (mtDir) {
230
+ console.error(`E_MULTI_TENANT_EXPORT_REFUSED: tenant-namespaced token stores exist under ${mtDir}; account import only operates on a flat single-owner store.`);
231
+ return 2;
232
+ }
173
233
  const file = argv[argv.indexOf('import') + 1];
174
234
  if (!file || file.startsWith('--')) {
175
235
  console.error('Usage: mcp-google-multi account import <bundle.enc> [--replace]');
@@ -1,6 +1,7 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/server";
2
2
  import { z } from 'zod';
3
3
  import { type Policy } from './write-control.js';
4
+ import { type AccountSet } from './accounts.js';
4
5
  import type { ArgShape } from './arg-normalize.js';
5
6
  import type { Metrics } from './usage-metrics.js';
6
7
  export type Cud = 'read' | 'create' | 'update' | 'delete';
@@ -37,10 +38,13 @@ export interface CatalogOperation {
37
38
  * real scope error rather than a bare "not found". */
38
39
  unreachable?: true;
39
40
  }
41
+ /** The service a tool name belongs to: its prefix, unless overridden. */
42
+ export declare function serviceOf(name: string): string;
40
43
  export declare function inferCud(name: string): Cud;
41
44
  export declare class ToolRegistry {
42
45
  private readonly server;
43
46
  private readonly metrics;
47
+ private readonly accounts;
44
48
  readonly tools: ToolEntry[];
45
49
  readonly policy: Policy;
46
50
  readonly registerTool: McpServer['registerTool'];
@@ -57,9 +61,15 @@ export declare class ToolRegistry {
57
61
  /** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
58
62
  * lazy surface to curated without touching the configured mode. */
59
63
  private expanded;
60
- constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode, metrics?: Metrics | null);
64
+ constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode, metrics?: Metrics | null, accounts?: () => AccountSet);
61
65
  registerMeta: McpServer['registerTool'];
62
66
  services(): string[];
67
+ /** The registry's OWN alias list: registration-time schema builders (the
68
+ * generated account enums) read this, never the process global. */
69
+ accountAliases(): readonly string[];
70
+ /** The registry's OWN account set: handlers needing an account's email or
71
+ * profile read this, never the process global. */
72
+ accountSet(): AccountSet;
63
73
  /** Declared input-schema keys + scalar kinds for one tool (tools/call arg
64
74
  * normalization; the kind drives value coercion on renamed keys). */
65
75
  argShape(name: string): ArgShape | undefined;
@@ -118,4 +128,11 @@ export declare class ToolRegistry {
118
128
  };
119
129
  installListHandler(): void;
120
130
  private toToolJson;
131
+ /** Advertise the CURRENT alias enum on every tools/list: account validation
132
+ * is (or is becoming) live, so a cached boot-time enum would go stale the
133
+ * moment account_add lands mid-session. The structural schema stays cached;
134
+ * only the account property's values are refreshed per call. Skipped for
135
+ * the subject-taking account tools (their `account` is the OPERAND, e.g. a
136
+ * brand-new alias name, not an identity to enumerate). */
137
+ private withLiveAccountEnum;
121
138
  }
package/dist/registry.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { z } from 'zod';
2
2
  import { isAllowed, writeDisabledResult, IRREVERSIBLE_TOOLS } from './write-control.js';
3
- import { getAccountSet, refreshAccountSetIfStale } from './accounts.js';
3
+ import { getAccountSet, isLiveAccountField, refreshAccountSetIfStale } from './accounts.js';
4
4
  import { compactResult, trimEnabled } from './trim.js';
5
- import { fanoutAccountField, invalidAccountsResult, parseAccountSelector, runFanout } from './fanout.js';
5
+ import { fanoutAccountField, invalidAccountsResult, noAccountsResult, parseAccountSelector, runFanout } from './fanout.js';
6
6
  import { MAX_RESPONSE_CHARS } from './executor.js';
7
- import { suggestKeys } from './arg-strict.js';
7
+ import { MAX_SUGGESTED_KEYS, suggestKeys } from './arg-strict.js';
8
8
  import { classifyScope } from './scope-observability.js';
9
+ import { hints } from './hint-provider.js';
9
10
  /** Nothing granted, no profile: asks the BUNDLE CATALOG whether a scope is
10
11
  * reachable at all, independently of any account. */
11
12
  const EMPTY_SCOPES = new Set();
@@ -40,6 +41,10 @@ const CUD_OVERRIDES = {
40
41
  const SERVICE_OVERRIDES = {
41
42
  reports_activities_list: 'admin',
42
43
  };
44
+ /** The service a tool name belongs to: its prefix, unless overridden. */
45
+ export function serviceOf(name) {
46
+ return Object.hasOwn(SERVICE_OVERRIDES, name) ? SERVICE_OVERRIDES[name] : name.includes('_') ? name.slice(0, name.indexOf('_')) : name;
47
+ }
43
48
  // read tools that write local files — same savePath fanned across accounts would clobber
44
49
  const FANOUT_EXCLUDE = new Set(['gmail_download_attachment', 'drive_download', 'drive_export']);
45
50
  /** Account-management tools: the `account` they take is the SUBJECT of the
@@ -64,7 +69,11 @@ function scalarKindOf(field) {
64
69
  }
65
70
  return 'other';
66
71
  }
67
- function isAccountEnum(field) {
72
+ /** A field naming one of the registry's accounts: a baked enum or a live
73
+ * account check (curated tools and generated tools both validate live). */
74
+ function isAccountSelector(field) {
75
+ if (isLiveAccountField(field))
76
+ return true;
68
77
  const def = field?._zod?.def;
69
78
  if (!def)
70
79
  return false;
@@ -91,6 +100,7 @@ export function inferCud(name) {
91
100
  export class ToolRegistry {
92
101
  server;
93
102
  metrics;
103
+ accounts;
94
104
  tools = [];
95
105
  policy;
96
106
  registerTool;
@@ -107,13 +117,18 @@ export class ToolRegistry {
107
117
  /** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
108
118
  * lazy surface to curated without touching the configured mode. */
109
119
  expanded = false;
110
- constructor(server, policy, mode = resolveDiscoveryMode(), metrics = null) {
120
+ constructor(server, policy, mode = resolveDiscoveryMode(), metrics = null,
121
+ // The registry's OWN account view: fan-out expansion, selector validation
122
+ // and default-account injection all read through it so a registry built
123
+ // over a subset never observes (or leaks) the global set.
124
+ accounts = getAccountSet) {
111
125
  this.server = server;
112
126
  this.metrics = metrics;
127
+ this.accounts = accounts;
113
128
  this.policy = policy;
114
129
  this.mode = mode;
115
130
  this.registerTool = ((name, config, handler) => {
116
- const service = SERVICE_OVERRIDES[name] ?? (name.includes('_') ? name.slice(0, name.indexOf('_')) : name);
131
+ const service = serviceOf(name);
117
132
  const cud = config.cud ?? inferCud(name);
118
133
  // destructiveHint=false claims "additive only" (MCP spec) — updates overwrite, so they stay true.
119
134
  // idempotent: reads trivially, deletes (already-gone = same), updates
@@ -140,14 +155,16 @@ export class ToolRegistry {
140
155
  let inputShape = config.inputSchema ?? {};
141
156
  let baseHandler = handler;
142
157
  const hasAccountField = 'account' in inputShape && !DEFAULT_ACCOUNT_EXCLUDE.has(name);
143
- if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) && isAccountEnum(inputShape.account)) {
158
+ if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) && isAccountSelector(inputShape.account)) {
144
159
  const description = inputShape.account.description ?? 'Google account alias';
145
- inputShape = { ...inputShape, account: fanoutAccountField(description) };
160
+ inputShape = { ...inputShape, account: fanoutAccountField(description, () => this.accounts().aliases) };
146
161
  baseHandler = async (...args) => {
147
162
  const first = args[0];
148
- const parsed = parseAccountSelector(typeof first?.account === 'string' ? first.account : '');
163
+ const parsed = parseAccountSelector(typeof first?.account === 'string' ? first.account : '', this.accounts().aliases);
149
164
  if (!parsed.ok)
150
- return invalidAccountsResult(parsed.invalid);
165
+ return invalidAccountsResult(parsed.invalid, this.accounts().aliases);
166
+ if (parsed.aliases.length === 0)
167
+ return noAccountsResult();
151
168
  if (!parsed.fanout)
152
169
  return handler({ ...first, account: parsed.aliases[0] }, ...args.slice(1));
153
170
  return runFanout(handler, args, parsed.aliases);
@@ -188,8 +205,10 @@ export class ToolRegistry {
188
205
  // heals for a client that always omits account (this branch never
189
206
  // reaches getClient's probe). One stat, only on omission.
190
207
  refreshAccountSetIfStale();
191
- const def = getAccountSet().defaultAccount;
208
+ const def = this.accounts().defaultAccount;
192
209
  if (!def) {
210
+ const aliases = this.accounts().aliases;
211
+ const rendered = hints()?.render({ kind: 'no_default_account', aliases });
193
212
  return {
194
213
  content: [
195
214
  {
@@ -197,7 +216,7 @@ export class ToolRegistry {
197
216
  text: JSON.stringify({
198
217
  error: 'E_NO_DEFAULT_ACCOUNT',
199
218
  message: 'No "account" given and no default account is configured.',
200
- hint: `Pass account explicitly (valid: ${getAccountSet().aliases.join(', ')}), or set GOOGLE_DEFAULT_ACCOUNT / "defaultAccount" in config.json.`,
219
+ hint: rendered?.hint ?? `Pass account explicitly (valid: ${aliases.join(', ')}), or set GOOGLE_DEFAULT_ACCOUNT / "defaultAccount" in config.json.`,
201
220
  retriable: false,
202
221
  }),
203
222
  },
@@ -219,7 +238,7 @@ export class ToolRegistry {
219
238
  const v = a?.account;
220
239
  if (typeof v !== 'string' || (v !== '*' && !v.includes(',')))
221
240
  return 1;
222
- const sel = parseAccountSelector(v);
241
+ const sel = parseAccountSelector(v, this.accounts().aliases);
223
242
  return sel.ok ? sel.aliases.length : 1;
224
243
  })
225
244
  : finalHandler;
@@ -239,6 +258,16 @@ export class ToolRegistry {
239
258
  services() {
240
259
  return [...new Set(this.tools.filter((t) => !t.meta).map((t) => t.service))];
241
260
  }
261
+ /** The registry's OWN alias list: registration-time schema builders (the
262
+ * generated account enums) read this, never the process global. */
263
+ accountAliases() {
264
+ return this.accounts().aliases;
265
+ }
266
+ /** The registry's OWN account set: handlers needing an account's email or
267
+ * profile read this, never the process global. */
268
+ accountSet() {
269
+ return this.accounts();
270
+ }
242
271
  /** Declared input-schema keys + scalar kinds for one tool (tools/call arg
243
272
  * normalization; the kind drives value coercion on renamed keys). */
244
273
  argShape(name) {
@@ -293,7 +322,7 @@ export class ToolRegistry {
293
322
  // case (parentId against parentFolderId) fails containment and edit
294
323
  // distance alike, which is the whole reason that matcher exists.
295
324
  const keys = candidates.map(([key]) => key);
296
- const hits = new Set(unknownKeys.flatMap((k) => suggestKeys(k, keys)));
325
+ const hits = new Set(unknownKeys.slice(0, MAX_SUGGESTED_KEYS).flatMap((k) => suggestKeys(k, keys)));
297
326
  return candidates
298
327
  .filter(([key]) => hits.has(key))
299
328
  .slice(0, 2)
@@ -440,9 +469,36 @@ export class ToolRegistry {
440
469
  return {
441
470
  name: tool.name,
442
471
  description: tool.description,
443
- inputSchema,
472
+ inputSchema: this.withLiveAccountEnum(tool, inputSchema),
444
473
  annotations: tool.annotations,
445
474
  ...(tool.clientMeta ? { _meta: tool.clientMeta } : {}),
446
475
  };
447
476
  }
477
+ /** Advertise the CURRENT alias enum on every tools/list: account validation
478
+ * is (or is becoming) live, so a cached boot-time enum would go stale the
479
+ * moment account_add lands mid-session. The structural schema stays cached;
480
+ * only the account property's values are refreshed per call. Skipped for
481
+ * the subject-taking account tools (their `account` is the OPERAND, e.g. a
482
+ * brand-new alias name, not an identity to enumerate). */
483
+ withLiveAccountEnum(tool, schema) {
484
+ if (DEFAULT_ACCOUNT_EXCLUDE.has(tool.name))
485
+ return schema;
486
+ const s = schema;
487
+ const account = s?.properties?.account;
488
+ if (!account)
489
+ return schema;
490
+ const aliases = this.accounts().aliases;
491
+ if (aliases.length === 0)
492
+ return schema;
493
+ if (Array.isArray(account.anyOf)) {
494
+ // fan-out union: advertise '*' + the live aliases on the selector branch
495
+ // (the one without the CSV pattern), keep the CSV branch as is
496
+ const isSelector = (b) => b.pattern === undefined;
497
+ if (!account.anyOf.some(isSelector))
498
+ return schema;
499
+ const anyOf = account.anyOf.map((b) => (isSelector(b) ? { ...b, enum: ['*', ...aliases] } : b));
500
+ return { ...s, properties: { ...s.properties, account: { ...account, anyOf } } };
501
+ }
502
+ return { ...s, properties: { ...s.properties, account: { ...account, enum: [...aliases] } } };
503
+ }
448
504
  }
@@ -14,7 +14,18 @@ export declare const BUNDLE_CATALOG: Record<string, BundleCatalogEntry>;
14
14
  /** One-way soft-rename map (UD5): empty at 6.0.0; the mechanism ships now so a
15
15
  * future rename is a deprecation warning, never a breaking config change. */
16
16
  export declare const BUNDLE_ALIASES: Record<string, string>;
17
+ /** Bundle names are user input: an Object.prototype member ("constructor",
18
+ * "toString") passes a plain `in` or index lookup, so membership is own-key only. */
19
+ export declare function isKnownBundle(name: string): boolean;
17
20
  export declare function resolveBundleAliases(bundles: string[]): string[];
18
21
  /** Closest catalog key for E_UNKNOWN_BUNDLE remediation (edit distance <= 2). */
19
22
  export declare function closestBundle(name: string): string | undefined;
23
+ /** Longest input any did-you-mean compares. Every name it suggests (tool,
24
+ * argument key, bundle, Discovery method id) is far shorter, and the inputs
25
+ * are caller-controlled: an unbounded comparison let one oversized name block
26
+ * the event loop for minutes. */
27
+ export declare const SUGGEST_MAX_INPUT = 128;
28
+ /** Levenshtein distance. Past SUGGEST_MAX_INPUT it returns the longer length
29
+ * without comparing: an upper bound on the true distance, and above every
30
+ * caller's accept threshold, so a capped pair is never suggested. */
20
31
  export declare function editDistance(a: string, b: string): number;
@@ -160,9 +160,14 @@ export const BUNDLE_CATALOG = {
160
160
  * future rename is a deprecation warning, never a breaking config change. */
161
161
  export const BUNDLE_ALIASES = {};
162
162
  const warnedAliases = new Set();
163
+ /** Bundle names are user input: an Object.prototype member ("constructor",
164
+ * "toString") passes a plain `in` or index lookup, so membership is own-key only. */
165
+ export function isKnownBundle(name) {
166
+ return Object.hasOwn(BUNDLE_CATALOG, name);
167
+ }
163
168
  export function resolveBundleAliases(bundles) {
164
169
  return bundles.map((name) => {
165
- const target = BUNDLE_ALIASES[name];
170
+ const target = Object.hasOwn(BUNDLE_ALIASES, name) ? BUNDLE_ALIASES[name] : undefined;
166
171
  if (!target)
167
172
  return name;
168
173
  if (!warnedAliases.has(name)) {
@@ -177,6 +182,8 @@ export function closestBundle(name) {
177
182
  let best;
178
183
  let bestDist = 3;
179
184
  for (const key of Object.keys(BUNDLE_CATALOG)) {
185
+ if (Math.abs(name.length - key.length) >= bestDist)
186
+ continue;
180
187
  const d = editDistance(name.toLowerCase(), key);
181
188
  if (d < bestDist) {
182
189
  bestDist = d;
@@ -185,14 +192,24 @@ export function closestBundle(name) {
185
192
  }
186
193
  return best;
187
194
  }
195
+ /** Longest input any did-you-mean compares. Every name it suggests (tool,
196
+ * argument key, bundle, Discovery method id) is far shorter, and the inputs
197
+ * are caller-controlled: an unbounded comparison let one oversized name block
198
+ * the event loop for minutes. */
199
+ export const SUGGEST_MAX_INPUT = 128;
200
+ /** Levenshtein distance. Past SUGGEST_MAX_INPUT it returns the longer length
201
+ * without comparing: an upper bound on the true distance, and above every
202
+ * caller's accept threshold, so a capped pair is never suggested. */
188
203
  export function editDistance(a, b) {
189
- const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
190
- for (let j = 1; j <= b.length; j++)
191
- dp[0][j] = j;
204
+ if (a.length > SUGGEST_MAX_INPUT || b.length > SUGGEST_MAX_INPUT)
205
+ return Math.max(a.length, b.length);
206
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
192
207
  for (let i = 1; i <= a.length; i++) {
208
+ const row = [i];
193
209
  for (let j = 1; j <= b.length; j++) {
194
- dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
210
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
195
211
  }
212
+ prev = row;
196
213
  }
197
- return dp[a.length][b.length];
214
+ return prev[b.length];
198
215
  }
@@ -1,4 +1,3 @@
1
- import { resolveScopesForAccount } from './auth.js';
2
1
  import { readToken } from './token-store.js';
3
2
  export type ScopeState = 'callable' | 'requestable_not_granted' | 'not_requestable';
4
3
  export type NotRequestableReason = 'add_bundle' | 'account_type' | 'unknown_scope';
@@ -35,12 +34,15 @@ export interface AccountScopeSets {
35
34
  granted: Set<string>;
36
35
  profile: Set<string>;
37
36
  }
37
+ /** Where a scope hint reads the granted token and the configured profile;
38
+ * absent = the single-owner token store and global registry. */
39
+ export interface ScopeSetDeps {
40
+ readTokenFn?: typeof readToken;
41
+ profileFn?: (alias: string) => string[];
42
+ }
38
43
  /** Best-effort per-account sets; null when the token/profile is unreadable
39
44
  * (decrypt error, missing token) — callers fall back to generic hints. */
40
- export declare function accountScopeSets(alias: string, deps?: {
41
- readTokenFn?: typeof readToken;
42
- profileFn?: typeof resolveScopesForAccount;
43
- }): AccountScopeSets | null;
45
+ export declare function accountScopeSets(alias: string, deps?: ScopeSetDeps): AccountScopeSets | null;
44
46
  /**
45
47
  * Runtime enrichment for a scope 403 on a method whose required scopes are
46
48
  * known (escape hatch: runtime Discovery; generated tools: baked). The
@@ -48,7 +50,7 @@ export declare function accountScopeSets(alias: string, deps?: {
48
50
  * profile means Google refuses it for this account type (personal Gmail),
49
51
  * so re-auth would loop — report the dead end instead.
50
52
  */
51
- export declare function scopeHintForMethod(required: readonly string[], alias: string, deps?: Parameters<typeof accountScopeSets>[1]): {
53
+ export declare function scopeHintForMethod(required: readonly string[], alias: string, deps?: ScopeSetDeps): {
52
54
  hint: string;
53
55
  retriable: boolean;
54
56
  } | null;
@@ -1,6 +1,8 @@
1
1
  import { ADMIN_SCOPES, BUNDLE_CATALOG } from './scope-catalog.js';
2
2
  import { BASE_SCOPES, resolveScopesForAccount } from './auth.js';
3
3
  import { readToken } from './token-store.js';
4
+ import { reauthHint } from './reauth-hint.js';
5
+ import { hints } from './hint-provider.js';
4
6
  // Reverse index scope -> bundles offering it, narrowest bundle first (fewest
5
7
  // scopes) so the remediation names the smallest grant. BASE_SCOPES map to the
6
8
  // pseudo-bundle "base" (outside a profile only via includesBase:false).
@@ -71,40 +73,54 @@ export function classifyMethodScopes(alternatives, granted, profile) {
71
73
  */
72
74
  export function scopeHint(scope, c, alias) {
73
75
  switch (c.state) {
74
- case 'requestable_not_granted':
76
+ case 'requestable_not_granted': {
77
+ const rendered = hints()?.render({ kind: 'scope_not_granted', account: alias, scope });
75
78
  return {
76
- hint: `Scope ${scope} is in ${alias}'s profile but not granted (you unchecked it at consent). ` +
77
- `Re-grant: npx mcp-google-multi auth --account ${alias}`,
78
- retriable: true,
79
+ hint: rendered?.hint ??
80
+ (`Scope ${scope} is in ${alias}'s profile but not granted (you unchecked it at consent). ` +
81
+ reauthHint(alias)),
82
+ // The ONE kind whose retriable the host may flip (Decision 3): the
83
+ // owner keeps true, a multi-tenant host may report false because the
84
+ // fix needs the user in a browser.
85
+ retriable: rendered?.retriable ?? true,
79
86
  };
87
+ }
80
88
  case 'not_requestable':
81
89
  if (c.reason === 'add_bundle') {
82
90
  // "base" is a pseudo-bundle (BASE_SCOPES excluded by includesBase:
83
91
  // false), NOT a catalog key — telling users to add it to bundles[]
84
92
  // would fail boot with E_UNKNOWN_BUNDLE.
85
93
  if (c.bundle === 'base') {
94
+ const rendered = hints()?.render({ kind: 'scope_base_excluded', account: alias, scope });
86
95
  return {
87
- hint: `Scope ${scope} is a base scope excluded by "includesBase": false in ${alias}'s profile. ` +
88
- `Set includesBase back to true (or remove it) in config.json, then npx mcp-google-multi auth --account ${alias}`,
96
+ hint: rendered?.hint ??
97
+ (`Scope ${scope} is a base scope excluded by "includesBase": false in ${alias}'s profile. ` +
98
+ `Set includesBase back to true (or remove it) in config.json, then npx mcp-google-multi auth --account ${alias}`),
89
99
  retriable: false,
90
100
  };
91
101
  }
102
+ const rendered = hints()?.render({ kind: 'scope_needs_bundle', account: alias, scope, bundle: c.bundle });
92
103
  return {
93
- hint: `Scope ${scope} needs the "${c.bundle}" bundle, which is not in ${alias}'s profile. ` +
94
- `Add it: set ${alias}'s profile bundles to include "${c.bundle}" in config.json, then npx mcp-google-multi auth --account ${alias}`,
104
+ hint: rendered?.hint ??
105
+ (`Scope ${scope} needs the "${c.bundle}" bundle, which is not in ${alias}'s profile. ` +
106
+ `Add it: set ${alias}'s profile bundles to include "${c.bundle}" in config.json, then npx mcp-google-multi auth --account ${alias}`),
95
107
  retriable: false,
96
108
  };
97
109
  }
98
110
  if (c.reason === 'account_type') {
111
+ const rendered = hints()?.render({ kind: 'scope_account_type', account: alias, scope });
99
112
  return {
100
- hint: `Scope ${scope} requires a Google Workspace account; ${alias} is a personal account and will 403. Use a Workspace alias. Do not retry.`,
113
+ hint: rendered?.hint ?? `Scope ${scope} requires a Google Workspace account; ${alias} is a personal account and will 403. Use a Workspace alias. Do not retry.`,
114
+ retriable: false,
115
+ };
116
+ }
117
+ {
118
+ const rendered = hints()?.render({ kind: 'scope_unknown_scope', scope });
119
+ return {
120
+ hint: rendered?.hint ?? `Scope ${scope} is not offered by this server (no bundle grants it). Not requestable. Do not retry.`,
101
121
  retriable: false,
102
122
  };
103
123
  }
104
- return {
105
- hint: `Scope ${scope} is not offered by this server (no bundle grants it). Not requestable. Do not retry.`,
106
- retriable: false,
107
- };
108
124
  default:
109
125
  return { hint: '', retriable: true };
110
126
  }