mcp-google-multi 6.0.0-beta.2 → 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 (181) hide show
  1. package/README.md +5 -5
  2. package/dist/accounts.d.ts +20 -4
  3. package/dist/accounts.js +68 -44
  4. package/dist/api-probe.d.ts +5 -0
  5. package/dist/api-probe.js +20 -15
  6. package/dist/arg-normalize.js +19 -38
  7. package/dist/arg-strict.d.ts +7 -0
  8. package/dist/arg-strict.js +38 -28
  9. package/dist/auth.d.ts +5 -4
  10. package/dist/auth.js +16 -23
  11. package/dist/boot-gates.d.ts +26 -0
  12. package/dist/boot-gates.js +36 -0
  13. package/dist/cimd-cache.d.ts +38 -0
  14. package/dist/cimd-cache.js +111 -0
  15. package/dist/client-config.d.ts +2 -2
  16. package/dist/client-config.js +8 -14
  17. package/dist/client.d.ts +32 -2
  18. package/dist/client.js +0 -0
  19. package/dist/compose.d.ts +18 -0
  20. package/dist/compose.js +109 -0
  21. package/dist/config-file.d.ts +13 -0
  22. package/dist/config-file.js +41 -10
  23. package/dist/discover.js +7 -10
  24. package/dist/discovery-client.d.ts +3 -0
  25. package/dist/discovery-client.js +36 -32
  26. package/dist/doctor.d.ts +9 -5
  27. package/dist/doctor.js +119 -29
  28. package/dist/env-load.js +2 -5
  29. package/dist/executor.d.ts +3 -0
  30. package/dist/executor.js +6 -6
  31. package/dist/fanout.d.ts +9 -1
  32. package/dist/fanout.js +47 -22
  33. package/dist/fs-atomic.d.ts +8 -0
  34. package/dist/fs-atomic.js +216 -29
  35. package/dist/hint-provider.d.ts +129 -0
  36. package/dist/hint-provider.js +58 -0
  37. package/dist/http-config.d.ts +2 -2
  38. package/dist/http-config.js +3 -7
  39. package/dist/http-transport.d.ts +67 -9
  40. package/dist/http-transport.js +318 -52
  41. package/dist/identity.d.ts +6 -3
  42. package/dist/identity.js +24 -8
  43. package/dist/index.js +76 -160
  44. package/dist/master-key.d.ts +3 -3
  45. package/dist/master-key.js +16 -19
  46. package/dist/mcp-token.d.ts +126 -19
  47. package/dist/mcp-token.js +557 -76
  48. package/dist/metrics-cli.d.ts +1 -1
  49. package/dist/metrics-cli.js +1 -1
  50. package/dist/metrics-tap.js +2 -5
  51. package/dist/migrate-config.d.ts +1 -2
  52. package/dist/migrate-config.js +5 -7
  53. package/dist/net-tuning.js +2 -4
  54. package/dist/oauth-as.d.ts +104 -4
  55. package/dist/oauth-as.js +368 -114
  56. package/dist/oauth-consent.d.ts +10 -0
  57. package/dist/oauth-consent.js +20 -10
  58. package/dist/outbound-allowlist.js +9 -11
  59. package/dist/reauth-hint.d.ts +1 -1
  60. package/dist/reauth-hint.js +11 -10
  61. package/dist/registry-transfer.d.ts +5 -0
  62. package/dist/registry-transfer.js +63 -11
  63. package/dist/registry.d.ts +13 -4
  64. package/dist/registry.js +100 -72
  65. package/dist/scope-catalog.d.ts +16 -0
  66. package/dist/scope-catalog.js +40 -13
  67. package/dist/scope-observability.d.ts +10 -9
  68. package/dist/scope-observability.js +34 -24
  69. package/dist/services.d.ts +18 -10
  70. package/dist/services.js +61 -22
  71. package/dist/ssrf-guard.d.ts +23 -9
  72. package/dist/ssrf-guard.js +106 -78
  73. package/dist/tenant-purge.d.ts +26 -0
  74. package/dist/tenant-purge.js +65 -0
  75. package/dist/token-store.d.ts +20 -2
  76. package/dist/token-store.js +0 -0
  77. package/dist/tools/_coerce.d.ts +4 -4
  78. package/dist/tools/_errors.d.ts +3 -0
  79. package/dist/tools/_errors.js +72 -60
  80. package/dist/tools/_local-files.d.ts +9 -0
  81. package/dist/tools/_local-files.js +22 -8
  82. package/dist/tools/account-wizard.d.ts +13 -3
  83. package/dist/tools/account-wizard.js +107 -56
  84. package/dist/tools/accounts-tool.d.ts +8 -1
  85. package/dist/tools/accounts-tool.js +35 -19
  86. package/dist/tools/admin.d.ts +10 -1
  87. package/dist/tools/admin.js +14 -11
  88. package/dist/tools/analytics.d.ts +10 -1
  89. package/dist/tools/analytics.js +14 -10
  90. package/dist/tools/calendar.d.ts +2 -1
  91. package/dist/tools/calendar.js +19 -18
  92. package/dist/tools/chat.d.ts +10 -1
  93. package/dist/tools/chat.js +12 -8
  94. package/dist/tools/contacts.d.ts +2 -1
  95. package/dist/tools/contacts.js +22 -19
  96. package/dist/tools/docs.d.ts +2 -1
  97. package/dist/tools/docs.js +35 -36
  98. package/dist/tools/drive.d.ts +2 -1
  99. package/dist/tools/drive.js +80 -74
  100. package/dist/tools/forms.d.ts +10 -1
  101. package/dist/tools/forms.js +18 -12
  102. package/dist/tools/generated/_shared.d.ts +2 -1
  103. package/dist/tools/generated/_shared.js +7 -3
  104. package/dist/tools/generated/admin.d.ts +2 -1
  105. package/dist/tools/generated/admin.js +265 -265
  106. package/dist/tools/generated/analytics.d.ts +2 -1
  107. package/dist/tools/generated/analytics.js +125 -125
  108. package/dist/tools/generated/appsmarket.d.ts +2 -1
  109. package/dist/tools/generated/appsmarket.js +5 -5
  110. package/dist/tools/generated/calendar.d.ts +2 -1
  111. package/dist/tools/generated/calendar.js +55 -55
  112. package/dist/tools/generated/chat.d.ts +2 -1
  113. package/dist/tools/generated/chat.js +95 -95
  114. package/dist/tools/generated/classroom.d.ts +2 -1
  115. package/dist/tools/generated/classroom.js +209 -209
  116. package/dist/tools/generated/cloudidentity.d.ts +2 -1
  117. package/dist/tools/generated/cloudidentity.js +133 -133
  118. package/dist/tools/generated/cloudsearch.d.ts +2 -1
  119. package/dist/tools/generated/cloudsearch.js +99 -99
  120. package/dist/tools/generated/contacts.d.ts +2 -1
  121. package/dist/tools/generated/contacts.js +29 -29
  122. package/dist/tools/generated/drive.d.ts +2 -1
  123. package/dist/tools/generated/drive.js +71 -71
  124. package/dist/tools/generated/driveactivity.d.ts +2 -1
  125. package/dist/tools/generated/driveactivity.js +3 -3
  126. package/dist/tools/generated/drivelabels.d.ts +2 -1
  127. package/dist/tools/generated/drivelabels.js +53 -53
  128. package/dist/tools/generated/forms.d.ts +2 -1
  129. package/dist/tools/generated/forms.js +7 -7
  130. package/dist/tools/generated/gmail.d.ts +2 -1
  131. package/dist/tools/generated/gmail.js +117 -117
  132. package/dist/tools/generated/groupsmigration.d.ts +2 -1
  133. package/dist/tools/generated/groupsmigration.js +3 -3
  134. package/dist/tools/generated/groupssettings.d.ts +2 -1
  135. package/dist/tools/generated/groupssettings.js +7 -7
  136. package/dist/tools/generated/index.d.ts +2 -1
  137. package/dist/tools/generated/keep.d.ts +2 -1
  138. package/dist/tools/generated/keep.js +15 -15
  139. package/dist/tools/generated/licensing.d.ts +2 -1
  140. package/dist/tools/generated/licensing.js +15 -15
  141. package/dist/tools/generated/meet.d.ts +2 -1
  142. package/dist/tools/generated/meet.js +27 -27
  143. package/dist/tools/generated/postmaster.d.ts +2 -1
  144. package/dist/tools/generated/postmaster.js +9 -9
  145. package/dist/tools/generated/reseller.d.ts +2 -1
  146. package/dist/tools/generated/reseller.js +35 -35
  147. package/dist/tools/generated/script.d.ts +2 -1
  148. package/dist/tools/generated/script.js +33 -33
  149. package/dist/tools/generated/searchconsole.d.ts +2 -1
  150. package/dist/tools/generated/searchconsole.js +3 -3
  151. package/dist/tools/generated/sheets.d.ts +2 -1
  152. package/dist/tools/generated/sheets.js +15 -15
  153. package/dist/tools/generated/tasks.d.ts +2 -1
  154. package/dist/tools/generated/tasks.js +5 -5
  155. package/dist/tools/generated/vault.d.ts +2 -1
  156. package/dist/tools/generated/vault.js +67 -67
  157. package/dist/tools/generated/workspaceevents.d.ts +2 -1
  158. package/dist/tools/generated/workspaceevents.js +31 -31
  159. package/dist/tools/gmail-mime.d.ts +1 -1
  160. package/dist/tools/gmail-mime.js +7 -12
  161. package/dist/tools/gmail.d.ts +4 -3
  162. package/dist/tools/gmail.js +67 -69
  163. package/dist/tools/google-api.d.ts +2 -3
  164. package/dist/tools/google-api.js +34 -22
  165. package/dist/tools/meet.d.ts +10 -1
  166. package/dist/tools/meet.js +12 -9
  167. package/dist/tools/searchconsole.d.ts +2 -1
  168. package/dist/tools/searchconsole.js +16 -13
  169. package/dist/tools/sheets.d.ts +2 -1
  170. package/dist/tools/sheets.js +35 -32
  171. package/dist/tools/slides.d.ts +10 -1
  172. package/dist/tools/slides.js +16 -10
  173. package/dist/tools/tasks.d.ts +2 -1
  174. package/dist/tools/tasks.js +18 -15
  175. package/dist/trim.d.ts +5 -0
  176. package/dist/trim.js +19 -0
  177. package/dist/usage-metrics.d.ts +23 -0
  178. package/dist/usage-metrics.js +67 -56
  179. package/dist/write-control.d.ts +4 -8
  180. package/dist/write-control.js +13 -6
  181. package/package.json +47 -21
package/dist/registry.js CHANGED
@@ -1,17 +1,17 @@
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();
12
- // Client-side result budget advertised for tools that do not declare their own
13
- // (fat readers do; see trim.ts). ~50k chars stays well inside a default client
14
- // context limit while leaving room for real list payloads.
13
+ // Client-side result budget for tools that do not declare their own (fat
14
+ // readers do; see trim.ts); stays well inside a default client context limit.
15
15
  const DEFAULT_MAX_RESULT_CHARS = 50_000;
16
16
  const DISCOVERY_MODES = ['lazy', 'curated', 'eager'];
17
17
  export function resolveDiscoveryMode(env = process.env) {
@@ -28,9 +28,8 @@ const CUD_OVERRIDES = {
28
28
  drive_transfer: 'create',
29
29
  // read-only resolver; the name's "resolve" verb would otherwise infer update.
30
30
  contacts_resolve: 'read',
31
- // Account management is gated by anthropic/requiresUserInteraction (forced
32
- // human approval), NOT the Google-data write-control profile — so onboarding
33
- // works under any profile. cud=read also keeps them off the fan-out path.
31
+ // Gated by anthropic/requiresUserInteraction, not write-control, so
32
+ // onboarding works under any profile; cud=read also keeps them off fan-out.
34
33
  account_add: 'read',
35
34
  account_reauth: 'read',
36
35
  // Writes a LOCAL MCP-client config, not Google data; gated by
@@ -40,12 +39,17 @@ const CUD_OVERRIDES = {
40
39
  const SERVICE_OVERRIDES = {
41
40
  reports_activities_list: 'admin',
42
41
  };
42
+ /** The service a tool name belongs to: its prefix, unless overridden. */
43
+ export function serviceOf(name) {
44
+ return Object.hasOwn(SERVICE_OVERRIDES, name) ? SERVICE_OVERRIDES[name] : name.includes('_') ? name.slice(0, name.indexOf('_')) : name;
45
+ }
43
46
  // read tools that write local files — same savePath fanned across accounts would clobber
44
47
  const FANOUT_EXCLUDE = new Set(['gmail_download_attachment', 'drive_download', 'drive_export']);
45
- /** Account-management tools: the `account` they take is the SUBJECT of the
46
- * operation, not the identity it runs as, so injecting the configured default
47
- * would silently retarget them. `account_add` would adopt the default as the
48
- * new alias, and `account_reauth` would re-authenticate the wrong account. */
48
+ // cud=read for gating/fan-out, yet they write (local files, the account
49
+ // registry, config): advertised as writes so annotation-keyed clients prompt.
50
+ const WRITES_DESPITE_READ = new Set([...FANOUT_EXCLUDE, 'account_add', 'account_reauth', 'account_write_config']);
51
+ // Their `account` is the SUBJECT of the operation (e.g. the new alias to add),
52
+ // not the identity to run as, so injecting the configured default would retarget them.
49
53
  const DEFAULT_ACCOUNT_EXCLUDE = new Set(['account_add', 'account_reauth']);
50
54
  /** Unwrap optional/default/nullable to the declared scalar kind (zod 4 defs). */
51
55
  function scalarKindOf(field) {
@@ -64,11 +68,15 @@ function scalarKindOf(field) {
64
68
  }
65
69
  return 'other';
66
70
  }
67
- function isAccountEnum(field) {
71
+ /** A field naming one of the registry's accounts: a baked enum or a live
72
+ * account check (curated tools and generated tools both validate live). */
73
+ function isAccountSelector(field) {
74
+ if (isLiveAccountField(field))
75
+ return true;
68
76
  const def = field?._zod?.def;
69
77
  if (!def)
70
78
  return false;
71
- // A2 made account enums .optional(); unwrap it or fan-out silently dies.
79
+ // Account enums are .optional(); unwrap it or fan-out silently dies.
72
80
  if (def.type === 'optional')
73
81
  return def.innerType?._zod?.def?.type === 'enum';
74
82
  return def.type === 'enum';
@@ -91,14 +99,14 @@ export function inferCud(name) {
91
99
  export class ToolRegistry {
92
100
  server;
93
101
  metrics;
102
+ accounts;
94
103
  tools = [];
95
104
  policy;
96
105
  registerTool;
97
106
  revealed = new Set();
98
107
  jsonSchemaCache = new Map();
99
108
  argShapeCache = new Map();
100
- /** Memoized isUngrantable verdict per tool: the answer depends on the frozen
101
- * bundle catalog only, so it never changes within a process. */
109
+ // Memoized isUngrantable verdict: depends on the frozen bundle catalog only.
102
110
  ungrantable = new Map();
103
111
  compactOutput = trimEnabled();
104
112
  registeringMeta = false;
@@ -107,30 +115,29 @@ export class ToolRegistry {
107
115
  /** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
108
116
  * lazy surface to curated without touching the configured mode. */
109
117
  expanded = false;
110
- constructor(server, policy, mode = resolveDiscoveryMode(), metrics = null) {
118
+ constructor(server, policy, mode = resolveDiscoveryMode(), metrics = null,
119
+ // The registry's OWN account view, so one built over a subset never
120
+ // observes (or leaks) the global set.
121
+ accounts = getAccountSet) {
111
122
  this.server = server;
112
123
  this.metrics = metrics;
124
+ this.accounts = accounts;
113
125
  this.policy = policy;
114
126
  this.mode = mode;
115
127
  this.registerTool = ((name, config, handler) => {
116
- const service = SERVICE_OVERRIDES[name] ?? (name.includes('_') ? name.slice(0, name.indexOf('_')) : name);
128
+ const service = serviceOf(name);
117
129
  const cud = config.cud ?? inferCud(name);
118
- // destructiveHint=false claims "additive only" (MCP spec) — updates overwrite, so they stay true.
119
- // idempotent: reads trivially, deletes (already-gone = same), updates
120
- // (overwrite converges); creates are not (send twice = two emails).
130
+ // Every write is destructive (a create is no less consequential than an
131
+ // update); idempotent iff not a create (send twice = two emails).
132
+ const writes = cud !== 'read' || WRITES_DESPITE_READ.has(name);
121
133
  const annotations = {
122
- readOnlyHint: cud === 'read',
123
- destructiveHint: cud === 'delete' || cud === 'update',
134
+ readOnlyHint: !writes,
135
+ destructiveHint: writes,
124
136
  idempotentHint: cud !== 'create',
125
137
  ...config.annotations,
126
138
  };
127
- // A12: forced per-call human approval on the irreversible set, even in
128
- // bypass mode. Client-enforced via the wire _meta (Claude Code reads
129
- // anthropic/* ONLY there); the server verdict stays separate.
130
- // Every tool also advertises a result-size budget: fat readers declare
131
- // their own, everything else inherits the default, so an uncapped tool
132
- // can never blow past a client's context limit. Generated tools align
133
- // with the executor's server-side cap.
139
+ // Irreversible-set approval and the result-size budget are both
140
+ // client-enforced via the wire _meta (anthropic/*); the server verdict stays separate.
134
141
  const clientMeta = {
135
142
  'anthropic/maxResultSizeChars': config.cud !== undefined ? MAX_RESPONSE_CHARS : DEFAULT_MAX_RESULT_CHARS,
136
143
  ...config._meta,
@@ -140,14 +147,16 @@ export class ToolRegistry {
140
147
  let inputShape = config.inputSchema ?? {};
141
148
  let baseHandler = handler;
142
149
  const hasAccountField = 'account' in inputShape && !DEFAULT_ACCOUNT_EXCLUDE.has(name);
143
- if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) && isAccountEnum(inputShape.account)) {
150
+ if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) && isAccountSelector(inputShape.account)) {
144
151
  const description = inputShape.account.description ?? 'Google account alias';
145
- inputShape = { ...inputShape, account: fanoutAccountField(description) };
152
+ inputShape = { ...inputShape, account: fanoutAccountField(description, () => this.accounts().aliases) };
146
153
  baseHandler = async (...args) => {
147
154
  const first = args[0];
148
- const parsed = parseAccountSelector(typeof first?.account === 'string' ? first.account : '');
155
+ const parsed = parseAccountSelector(typeof first?.account === 'string' ? first.account : '', this.accounts().aliases);
149
156
  if (!parsed.ok)
150
- return invalidAccountsResult(parsed.invalid);
157
+ return invalidAccountsResult(parsed.invalid, this.accounts().aliases);
158
+ if (parsed.aliases.length === 0)
159
+ return noAccountsResult();
151
160
  if (!parsed.fanout)
152
161
  return handler({ ...first, account: parsed.aliases[0] }, ...args.slice(1));
153
162
  return runFanout(handler, args, parsed.aliases);
@@ -173,10 +182,8 @@ export class ToolRegistry {
173
182
  : writeDisabledResult({ name, service, cud, scopes: config.requiredScopes }, policy,
174
183
  // Concrete by here: default-account injection wraps this.
175
184
  args[0]?.account);
176
- // A2: the ONE default-account injection site — outside the CUD gate and
177
- // the fan-out parse so both observe a concrete alias; NOT meta-skipped
178
- // (that is what covers google_api_call with zero bespoke code). Explicit
179
- // aliases, "*" and CSV pass through untouched.
185
+ // The ONE default-account injection site, outside the CUD gate and the
186
+ // fan-out parse (not meta-skipped, which is what covers google_api_call).
180
187
  const withDefault = !hasAccountField
181
188
  ? guarded
182
189
  : (...args) => {
@@ -184,12 +191,13 @@ export class ToolRegistry {
184
191
  const value = first?.account;
185
192
  if (value != null && value !== '')
186
193
  return guarded(...args);
187
- // Refresh here or the unset->configured default transition never
188
- // heals for a client that always omits account (this branch never
189
- // reaches getClient's probe). One stat, only on omission.
194
+ // Refresh here, or a client that always omits account never heals
195
+ // the unset->configured default transition (getClient's probe is unreached).
190
196
  refreshAccountSetIfStale();
191
- const def = getAccountSet().defaultAccount;
197
+ const def = this.accounts().defaultAccount;
192
198
  if (!def) {
199
+ const aliases = this.accounts().aliases;
200
+ const rendered = hints()?.render({ kind: 'no_default_account', aliases });
193
201
  return {
194
202
  content: [
195
203
  {
@@ -197,7 +205,7 @@ export class ToolRegistry {
197
205
  text: JSON.stringify({
198
206
  error: 'E_NO_DEFAULT_ACCOUNT',
199
207
  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.`,
208
+ hint: rendered?.hint ?? `Pass account explicitly (valid: ${aliases.join(', ')}), or set GOOGLE_DEFAULT_ACCOUNT / "defaultAccount" in config.json.`,
201
209
  retriable: false,
202
210
  }),
203
211
  },
@@ -210,16 +218,14 @@ export class ToolRegistry {
210
218
  const finalHandler = this.compactOutput
211
219
  ? async (...args) => compactResult(await withDefault(...args))
212
220
  : withDefault;
213
- // Usage metrics wrap OUTERMOST and only when enabled: off means no
214
- // wrapper exists and the chain is byte-identical to the pre-metrics
215
- // chain. Fan-out width is derived here (bucketed in the module) so the
216
- // metrics module never imports fanout.
221
+ // Usage metrics wrap outermost, only when enabled (off = byte-identical
222
+ // to the pre-metrics chain); fan-out width is derived here so the metrics module never imports fanout.
217
223
  const instrumented = this.metrics
218
224
  ? this.metrics.wrap({ name, service, meta: this.registeringMeta, generated: config.cud !== undefined }, finalHandler, (a) => {
219
225
  const v = a?.account;
220
226
  if (typeof v !== 'string' || (v !== '*' && !v.includes(',')))
221
227
  return 1;
222
- const sel = parseAccountSelector(v);
228
+ const sel = parseAccountSelector(v, this.accounts().aliases);
223
229
  return sel.ok ? sel.aliases.length : 1;
224
230
  })
225
231
  : finalHandler;
@@ -239,6 +245,16 @@ export class ToolRegistry {
239
245
  services() {
240
246
  return [...new Set(this.tools.filter((t) => !t.meta).map((t) => t.service))];
241
247
  }
248
+ /** The registry's OWN alias list: registration-time schema builders (the
249
+ * generated account enums) read this, never the process global. */
250
+ accountAliases() {
251
+ return this.accounts().aliases;
252
+ }
253
+ /** The registry's OWN account set: handlers needing an account's email or
254
+ * profile read this, never the process global. */
255
+ accountSet() {
256
+ return this.accounts();
257
+ }
242
258
  /** Declared input-schema keys + scalar kinds for one tool (tools/call arg
243
259
  * normalization; the kind drives value coercion on renamed keys). */
244
260
  argShape(name) {
@@ -272,10 +288,8 @@ export class ToolRegistry {
272
288
  const declared = new Set(Object.keys(self.inputShape));
273
289
  const byKey = new Map();
274
290
  for (const t of this.tools) {
275
- // Only tools the agent can actually see in tools/list. A hint naming a
276
- // registered-but-unadvertised generated tool turns a recoverable error
277
- // into a dead end: in curated mode 353 tools are registered and 181 are
278
- // advertised, and the hint used to reach for any of them.
291
+ // Only tools visible in tools/list: a hint naming a hidden generated
292
+ // tool would turn a recoverable error into a dead end.
279
293
  if (t.service !== self.service || t.name === tool || !this.isVisible(t))
280
294
  continue;
281
295
  for (const key of Object.keys(t.inputShape)) {
@@ -289,11 +303,10 @@ export class ToolRegistry {
289
303
  }
290
304
  }
291
305
  const candidates = [...byKey.entries()].filter(([, e]) => e.tools.length >= 2 || e.curated);
292
- // Reuse the tiered matcher rather than a substring test: the motivating
293
- // case (parentId against parentFolderId) fails containment and edit
294
- // distance alike, which is the whole reason that matcher exists.
306
+ // The tiered matcher, not a substring test: parentId vs parentFolderId
307
+ // fails both containment and edit distance.
295
308
  const keys = candidates.map(([key]) => key);
296
- const hits = new Set(unknownKeys.flatMap((k) => suggestKeys(k, keys)));
309
+ const hits = new Set(unknownKeys.slice(0, MAX_SUGGESTED_KEYS).flatMap((k) => suggestKeys(k, keys)));
297
310
  return candidates
298
311
  .filter(([key]) => hits.has(key))
299
312
  .slice(0, 2)
@@ -357,7 +370,7 @@ export class ToolRegistry {
357
370
  return true;
358
371
  }
359
372
  /** discover_reset: back to the lean meta-only surface. Clears reveals too.
360
- * BV-8: if a client mishandles a SHRINKING tools/list, this degrades to a
373
+ * If a client mishandles a SHRINKING tools/list, this degrades to a
361
374
  * no-op for that session — tools stay callable regardless (graceful
362
375
  * dispatch), zero correctness impact. */
363
376
  collapse() {
@@ -387,11 +400,8 @@ export class ToolRegistry {
387
400
  const cached = this.ungrantable.get(tool.name);
388
401
  if (cached !== undefined)
389
402
  return cached;
390
- // EVERY alternative must be ungrantable. `classifyMethodScopes` cannot
391
- // answer this: `add_bundle` and `unknown_scope` share the
392
- // `not_requestable` rank, so its first-best match hides a grantable
393
- // alternative that appears later in the list. Discovery scope lists are
394
- // ANY-OF, so one grantable alternative makes the method reachable.
403
+ // EVERY alternative must be ungrantable (scope lists are ANY-OF); a
404
+ // first-best classifyScope match would hide a grantable one later in the list.
395
405
  const verdict = tool.requiredScopes.every((scope) => {
396
406
  const c = classifyScope(scope, EMPTY_SCOPES, EMPTY_SCOPES);
397
407
  return c.state === 'not_requestable' && c.reason === 'unknown_scope';
@@ -401,10 +411,8 @@ export class ToolRegistry {
401
411
  }
402
412
  isVisible(tool) {
403
413
  const mode = this.effectiveMode();
404
- // Advertising a tool that can never succeed spends context budget to hand
405
- // the agent a dead end. It stays registered and callable, so calling it by
406
- // name still produces the real scope error plus its remediation, and
407
- // {service}_discover still lists it, marked.
414
+ // Hidden, not unregistered: calling an ungrantable tool by name still
415
+ // produces the real scope error, and {service}_discover still lists it, marked.
408
416
  if (!tool.meta && this.isUngrantable(tool))
409
417
  return false;
410
418
  return (tool.meta ||
@@ -424,10 +432,7 @@ export class ToolRegistry {
424
432
  throw new Error('installListHandler() requires at least one registered tool');
425
433
  }
426
434
  this.server.server.setRequestHandler('tools/list', async () => ({
427
- // The wire Tool is hand-built because the SDK's own types drop the
428
- // anthropic/* _meta keys honoring clients read. z.toJSONSchema emits a
429
- // valid draft-7 object schema by construction, which the SDK's recursive
430
- // JSON-Schema type cannot infer from our cached `unknown`.
435
+ // Hand-built: the SDK's own Tool type drops the anthropic/* _meta keys honoring clients read.
431
436
  tools: this.tools.filter((t) => this.isVisible(t)).map((t) => this.toToolJson(t)),
432
437
  }));
433
438
  }
@@ -440,9 +445,32 @@ export class ToolRegistry {
440
445
  return {
441
446
  name: tool.name,
442
447
  description: tool.description,
443
- inputSchema,
448
+ inputSchema: this.withLiveAccountEnum(tool, inputSchema),
444
449
  annotations: tool.annotations,
445
450
  ...(tool.clientMeta ? { _meta: tool.clientMeta } : {}),
446
451
  };
447
452
  }
453
+ // Refreshes the account enum's values per call (structural schema stays
454
+ // cached), so a cached boot-time list doesn't go stale once account_add runs mid-session.
455
+ withLiveAccountEnum(tool, schema) {
456
+ if (DEFAULT_ACCOUNT_EXCLUDE.has(tool.name))
457
+ return schema;
458
+ const s = schema;
459
+ const account = s?.properties?.account;
460
+ if (!account)
461
+ return schema;
462
+ const aliases = this.accounts().aliases;
463
+ if (aliases.length === 0)
464
+ return schema;
465
+ if (Array.isArray(account.anyOf)) {
466
+ // fan-out union: advertise '*' + the live aliases on the selector branch
467
+ // (the one without the CSV pattern), keep the CSV branch as is
468
+ const isSelector = (b) => b.pattern === undefined;
469
+ if (!account.anyOf.some(isSelector))
470
+ return schema;
471
+ const anyOf = account.anyOf.map((b) => (isSelector(b) ? { ...b, enum: ['*', ...aliases] } : b));
472
+ return { ...s, properties: { ...s.properties, account: { ...account, anyOf } } };
473
+ }
474
+ return { ...s, properties: { ...s.properties, account: { ...account, enum: [...aliases] } } };
475
+ }
448
476
  }
@@ -8,13 +8,29 @@ export interface BundleCatalogEntry {
8
8
  description: string;
9
9
  risk: 'low' | 'medium' | 'high';
10
10
  workspaceOnly?: boolean;
11
+ personalRefused?: boolean;
11
12
  }
12
13
  export declare const ADMIN_SCOPES: string[];
13
14
  export declare const BUNDLE_CATALOG: Record<string, BundleCatalogEntry>;
14
15
  /** One-way soft-rename map (UD5): empty at 6.0.0; the mechanism ships now so a
15
16
  * future rename is a deprecation warning, never a breaking config change. */
16
17
  export declare const BUNDLE_ALIASES: Record<string, string>;
18
+ /** Bundle names are user input: an Object.prototype member ("constructor",
19
+ * "toString") passes a plain `in` or index lookup, so membership is own-key only. */
20
+ export declare function isKnownBundle(name: string): boolean;
21
+ /** A gmail.com or googlemail.com address, which is always a personal Google account. */
22
+ export declare function isPersonalGoogleAddress(email: string): boolean;
23
+ /** The bundles in `bundles` that Google refuses on the consent screen of the account at `email`. */
24
+ export declare function refusedBundlesFor(email: string, bundles: readonly string[]): string[];
17
25
  export declare function resolveBundleAliases(bundles: string[]): string[];
18
26
  /** Closest catalog key for E_UNKNOWN_BUNDLE remediation (edit distance <= 2). */
19
27
  export declare function closestBundle(name: string): string | undefined;
28
+ /** Longest input any did-you-mean compares. Every name it suggests (tool,
29
+ * argument key, bundle, Discovery method id) is far shorter, and the inputs
30
+ * are caller-controlled: an unbounded comparison let one oversized name block
31
+ * the event loop for minutes. */
32
+ export declare const SUGGEST_MAX_INPUT = 128;
33
+ /** Levenshtein distance. Past SUGGEST_MAX_INPUT it returns the longer length
34
+ * without comparing: an upper bound on the true distance, and above every
35
+ * caller's accept threshold, so a capped pair is never suggested. */
20
36
  export declare function editDistance(a: string, b: string): number;
@@ -1,7 +1,5 @@
1
- // The curated bundle catalog: ONE source of truth consumed by the wizard
2
- // picker, scope observability (#114) hints, and docs. Keys are a frozen
3
- // public API (config.json references them by name) — renames go through
4
- // BUNDLE_ALIASES, never in place.
1
+ // The curated bundle catalog: ONE source of truth for the wizard, scope
2
+ // observability, and docs. Keys are a frozen public API; rename via BUNDLE_ALIASES only.
5
3
  export const ADMIN_SCOPES = [
6
4
  'https://www.googleapis.com/auth/admin.reports.audit.readonly',
7
5
  'https://www.googleapis.com/auth/admin.directory.user',
@@ -63,6 +61,7 @@ export const BUNDLE_CATALOG = {
63
61
  ],
64
62
  description: 'Manage Cloud Identity groups and devices across the directory.',
65
63
  risk: 'high',
64
+ personalRefused: true,
66
65
  },
67
66
  cloudsearch: {
68
67
  scopes: ['https://www.googleapis.com/auth/cloud_search'],
@@ -79,6 +78,7 @@ export const BUNDLE_CATALOG = {
79
78
  scopes: ['https://www.googleapis.com/auth/keep'],
80
79
  description: 'Read and edit Google Keep notes.',
81
80
  risk: 'low',
81
+ personalRefused: true,
82
82
  },
83
83
  driveactivity: {
84
84
  scopes: ['https://www.googleapis.com/auth/drive.activity.readonly'],
@@ -113,9 +113,8 @@ export const BUNDLE_CATALOG = {
113
113
  description: 'Read Google Analytics (GA4): run reports and inspect accounts, properties and their configuration.',
114
114
  risk: 'low',
115
115
  },
116
- // Includes readonly so it is self-sufficient: analytics.edit alone does not
117
- // authorize Data API reads. The smaller `analytics` bundle stays the hint
118
- // target for read scopes (scope-observability sorts bundles by size).
116
+ // Includes readonly so it's self-sufficient: analytics.edit alone doesn't
117
+ // authorize Data API reads.
119
118
  analytics_write: {
120
119
  scopes: [
121
120
  'https://www.googleapis.com/auth/analytics.readonly',
@@ -160,9 +159,25 @@ export const BUNDLE_CATALOG = {
160
159
  * future rename is a deprecation warning, never a breaking config change. */
161
160
  export const BUNDLE_ALIASES = {};
162
161
  const warnedAliases = new Set();
162
+ /** Bundle names are user input: an Object.prototype member ("constructor",
163
+ * "toString") passes a plain `in` or index lookup, so membership is own-key only. */
164
+ export function isKnownBundle(name) {
165
+ return Object.hasOwn(BUNDLE_CATALOG, name);
166
+ }
167
+ const PERSONAL_ADDRESS_RE = /@(?:gmail|googlemail)\.com$/i;
168
+ /** A gmail.com or googlemail.com address, which is always a personal Google account. */
169
+ export function isPersonalGoogleAddress(email) {
170
+ return PERSONAL_ADDRESS_RE.test(email.trim());
171
+ }
172
+ /** The bundles in `bundles` that Google refuses on the consent screen of the account at `email`. */
173
+ export function refusedBundlesFor(email, bundles) {
174
+ if (!isPersonalGoogleAddress(email))
175
+ return [];
176
+ return bundles.filter((b) => isKnownBundle(b) && BUNDLE_CATALOG[b].personalRefused === true);
177
+ }
163
178
  export function resolveBundleAliases(bundles) {
164
179
  return bundles.map((name) => {
165
- const target = BUNDLE_ALIASES[name];
180
+ const target = Object.hasOwn(BUNDLE_ALIASES, name) ? BUNDLE_ALIASES[name] : undefined;
166
181
  if (!target)
167
182
  return name;
168
183
  if (!warnedAliases.has(name)) {
@@ -177,6 +192,8 @@ export function closestBundle(name) {
177
192
  let best;
178
193
  let bestDist = 3;
179
194
  for (const key of Object.keys(BUNDLE_CATALOG)) {
195
+ if (Math.abs(name.length - key.length) >= bestDist)
196
+ continue;
180
197
  const d = editDistance(name.toLowerCase(), key);
181
198
  if (d < bestDist) {
182
199
  bestDist = d;
@@ -185,14 +202,24 @@ export function closestBundle(name) {
185
202
  }
186
203
  return best;
187
204
  }
205
+ /** Longest input any did-you-mean compares. Every name it suggests (tool,
206
+ * argument key, bundle, Discovery method id) is far shorter, and the inputs
207
+ * are caller-controlled: an unbounded comparison let one oversized name block
208
+ * the event loop for minutes. */
209
+ export const SUGGEST_MAX_INPUT = 128;
210
+ /** Levenshtein distance. Past SUGGEST_MAX_INPUT it returns the longer length
211
+ * without comparing: an upper bound on the true distance, and above every
212
+ * caller's accept threshold, so a capped pair is never suggested. */
188
213
  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;
214
+ if (a.length > SUGGEST_MAX_INPUT || b.length > SUGGEST_MAX_INPUT)
215
+ return Math.max(a.length, b.length);
216
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
192
217
  for (let i = 1; i <= a.length; i++) {
218
+ const row = [i];
193
219
  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));
220
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
195
221
  }
222
+ prev = row;
196
223
  }
197
- return dp[a.length][b.length];
224
+ return prev[b.length];
198
225
  }
@@ -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;
@@ -63,7 +65,7 @@ export interface ScopesReport {
63
65
  bundle: string;
64
66
  }[];
65
67
  /** Runtime-signal bucket: static classification cannot prove account
66
- * type; doctor (B9) fills it from observed persistent admin 403s. */
68
+ * type; doctor fills it from observed persistent admin 403s. */
67
69
  accountType: string[];
68
70
  unknown: string[];
69
71
  };
@@ -71,8 +73,7 @@ export interface ScopesReport {
71
73
  }
72
74
  /**
73
75
  * The account_list / doctor block. Universe = profile ∪ granted ∪ the scopes
74
- * required by REGISTERED tools ("registered but not requestable" is the
75
- * original #114 ask).
76
+ * required by REGISTERED tools ("registered but not requestable" included).
76
77
  */
77
78
  export declare function buildScopesReport(granted: ReadonlySet<string>, profile: ReadonlySet<string>, registeredScopes?: Iterable<string>): ScopesReport;
78
79
  export declare function clearScopeIndexForTest(): void;
@@ -1,9 +1,10 @@
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
- // Reverse index scope -> bundles offering it, narrowest bundle first (fewest
5
- // scopes) so the remediation names the smallest grant. BASE_SCOPES map to the
6
- // pseudo-bundle "base" (outside a profile only via includesBase:false).
4
+ import { reauthHint } from './reauth-hint.js';
5
+ import { hints } from './hint-provider.js';
6
+ // Reverse index scope -> bundles offering it, narrowest first, so the
7
+ // remediation names the smallest grant.
7
8
  let reverseIndex = null;
8
9
  function scopeIndex() {
9
10
  if (reverseIndex)
@@ -71,40 +72,51 @@ export function classifyMethodScopes(alternatives, granted, profile) {
71
72
  */
72
73
  export function scopeHint(scope, c, alias) {
73
74
  switch (c.state) {
74
- case 'requestable_not_granted':
75
+ case 'requestable_not_granted': {
76
+ const rendered = hints()?.render({ kind: 'scope_not_granted', account: alias, scope });
75
77
  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,
78
+ hint: rendered?.hint ??
79
+ (`Scope ${scope} is in ${alias}'s profile but not granted (you unchecked it at consent). ` +
80
+ reauthHint(alias)),
81
+ // The ONE kind whose retriable the host may flip, since the fix needs the user in a browser.
82
+ retriable: rendered?.retriable ?? true,
79
83
  };
84
+ }
80
85
  case 'not_requestable':
81
86
  if (c.reason === 'add_bundle') {
82
- // "base" is a pseudo-bundle (BASE_SCOPES excluded by includesBase:
83
- // false), NOT a catalog key — telling users to add it to bundles[]
84
- // would fail boot with E_UNKNOWN_BUNDLE.
87
+ // "base" is a pseudo-bundle, not a catalog key: telling users to add
88
+ // it to bundles[] would fail boot with E_UNKNOWN_BUNDLE.
85
89
  if (c.bundle === 'base') {
90
+ const rendered = hints()?.render({ kind: 'scope_base_excluded', account: alias, scope });
86
91
  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}`,
92
+ hint: rendered?.hint ??
93
+ (`Scope ${scope} is a base scope excluded by "includesBase": false in ${alias}'s profile. ` +
94
+ `Set includesBase back to true (or remove it) in config.json, then npx mcp-google-multi auth --account ${alias}`),
89
95
  retriable: false,
90
96
  };
91
97
  }
98
+ const rendered = hints()?.render({ kind: 'scope_needs_bundle', account: alias, scope, bundle: c.bundle });
92
99
  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}`,
100
+ hint: rendered?.hint ??
101
+ (`Scope ${scope} needs the "${c.bundle}" bundle, which is not in ${alias}'s profile. ` +
102
+ `Add it: set ${alias}'s profile bundles to include "${c.bundle}" in config.json, then npx mcp-google-multi auth --account ${alias}`),
95
103
  retriable: false,
96
104
  };
97
105
  }
98
106
  if (c.reason === 'account_type') {
107
+ const rendered = hints()?.render({ kind: 'scope_account_type', account: alias, scope });
99
108
  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.`,
109
+ 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.`,
110
+ retriable: false,
111
+ };
112
+ }
113
+ {
114
+ const rendered = hints()?.render({ kind: 'scope_unknown_scope', scope });
115
+ return {
116
+ hint: rendered?.hint ?? `Scope ${scope} is not offered by this server (no bundle grants it). Not requestable. Do not retry.`,
101
117
  retriable: false,
102
118
  };
103
119
  }
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
120
  default:
109
121
  return { hint: '', retriable: true };
110
122
  }
@@ -137,9 +149,8 @@ export function scopeHintForMethod(required, alias, deps = {}) {
137
149
  if (!best || best.state === 'callable')
138
150
  return null; // 403 despite grants: not a scope-classification problem
139
151
  const base = scopeHint(best.scope, best.classification, alias);
140
- // Hedged, never fabricated: an in-profile admin scope that 403s is EITHER a
141
- // Workspace account needing re-auth OR a personal account that can never be
142
- // granted admin — the inputs cannot distinguish, so say both.
152
+ // Hedged, never fabricated: the inputs can't distinguish a Workspace account
153
+ // needing re-auth from a personal account that can never grant admin, so say both.
143
154
  if (best.state === 'requestable_not_granted' && ADMIN_SCOPES.includes(best.scope)) {
144
155
  return {
145
156
  hint: `${base.hint} Note: personal (non-Workspace) accounts can never grant admin scopes — if ${alias} is a personal Gmail account, use a Workspace alias instead.`,
@@ -150,8 +161,7 @@ export function scopeHintForMethod(required, alias, deps = {}) {
150
161
  }
151
162
  /**
152
163
  * The account_list / doctor block. Universe = profile ∪ granted ∪ the scopes
153
- * required by REGISTERED tools ("registered but not requestable" is the
154
- * original #114 ask).
164
+ * required by REGISTERED tools ("registered but not requestable" included).
155
165
  */
156
166
  export function buildScopesReport(granted, profile, registeredScopes = []) {
157
167
  const callable = [];