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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +5 -5
  2. package/dist/accounts.d.ts +3 -3
  3. package/dist/accounts.js +18 -31
  4. package/dist/arg-normalize.js +15 -35
  5. package/dist/arg-strict.js +8 -28
  6. package/dist/auth.d.ts +1 -1
  7. package/dist/auth.js +5 -10
  8. package/dist/boot-gates.d.ts +1 -1
  9. package/dist/boot-gates.js +3 -5
  10. package/dist/cimd-cache.js +5 -11
  11. package/dist/client-config.d.ts +2 -2
  12. package/dist/client-config.js +8 -14
  13. package/dist/client.js +0 -0
  14. package/dist/compose.d.ts +8 -1
  15. package/dist/compose.js +11 -11
  16. package/dist/config-file.js +7 -12
  17. package/dist/discover.js +4 -9
  18. package/dist/discovery-client.js +14 -33
  19. package/dist/doctor.d.ts +3 -3
  20. package/dist/doctor.js +12 -18
  21. package/dist/env-load.js +2 -5
  22. package/dist/executor.js +2 -4
  23. package/dist/fanout.js +8 -17
  24. package/dist/fs-atomic.d.ts +0 -3
  25. package/dist/fs-atomic.js +6 -10
  26. package/dist/hint-provider.js +4 -10
  27. package/dist/http-config.d.ts +2 -2
  28. package/dist/http-config.js +3 -7
  29. package/dist/http-transport.d.ts +5 -5
  30. package/dist/http-transport.js +39 -92
  31. package/dist/identity.d.ts +2 -2
  32. package/dist/identity.js +6 -9
  33. package/dist/index.js +19 -31
  34. package/dist/master-key.d.ts +3 -3
  35. package/dist/master-key.js +11 -22
  36. package/dist/mcp-token.d.ts +3 -13
  37. package/dist/mcp-token.js +16 -32
  38. package/dist/metrics-cli.d.ts +1 -1
  39. package/dist/metrics-cli.js +1 -1
  40. package/dist/metrics-tap.js +2 -5
  41. package/dist/migrate-config.d.ts +1 -2
  42. package/dist/migrate-config.js +3 -5
  43. package/dist/net-tuning.js +2 -4
  44. package/dist/oauth-as.d.ts +2 -2
  45. package/dist/oauth-as.js +32 -73
  46. package/dist/oauth-consent.js +4 -10
  47. package/dist/outbound-allowlist.js +4 -9
  48. package/dist/reauth-hint.js +2 -5
  49. package/dist/registry-transfer.d.ts +1 -1
  50. package/dist/registry-transfer.js +5 -13
  51. package/dist/registry.d.ts +1 -9
  52. package/dist/registry.js +38 -66
  53. package/dist/scope-catalog.d.ts +5 -0
  54. package/dist/scope-catalog.js +17 -7
  55. package/dist/scope-observability.d.ts +2 -3
  56. package/dist/scope-observability.js +8 -14
  57. package/dist/services.d.ts +3 -0
  58. package/dist/services.js +5 -10
  59. package/dist/ssrf-guard.js +11 -22
  60. package/dist/tenant-purge.js +2 -4
  61. package/dist/token-store.js +0 -0
  62. package/dist/tools/_coerce.d.ts +4 -4
  63. package/dist/tools/_errors.js +22 -39
  64. package/dist/tools/_local-files.js +4 -8
  65. package/dist/tools/account-wizard.d.ts +4 -3
  66. package/dist/tools/account-wizard.js +48 -54
  67. package/dist/tools/accounts-tool.d.ts +1 -1
  68. package/dist/tools/accounts-tool.js +1 -1
  69. package/dist/tools/admin.js +2 -3
  70. package/dist/tools/analytics.js +4 -5
  71. package/dist/tools/calendar.js +4 -7
  72. package/dist/tools/chat.js +2 -3
  73. package/dist/tools/contacts.js +8 -9
  74. package/dist/tools/docs.js +4 -9
  75. package/dist/tools/drive.js +18 -27
  76. package/dist/tools/forms.js +2 -3
  77. package/dist/tools/gmail-mime.d.ts +1 -1
  78. package/dist/tools/gmail-mime.js +7 -12
  79. package/dist/tools/gmail.d.ts +2 -2
  80. package/dist/tools/gmail.js +26 -40
  81. package/dist/tools/google-api.js +5 -7
  82. package/dist/tools/meet.js +2 -3
  83. package/dist/tools/searchconsole.js +2 -3
  84. package/dist/tools/sheets.js +2 -3
  85. package/dist/tools/slides.js +2 -3
  86. package/dist/tools/tasks.js +2 -3
  87. package/dist/trim.js +2 -3
  88. package/dist/usage-metrics.js +5 -12
  89. package/dist/write-control.d.ts +4 -8
  90. package/dist/write-control.js +3 -4
  91. package/package.json +21 -21
package/dist/registry.js CHANGED
@@ -10,9 +10,8 @@ import { hints } from './hint-provider.js';
10
10
  /** Nothing granted, no profile: asks the BUNDLE CATALOG whether a scope is
11
11
  * reachable at all, independently of any account. */
12
12
  const EMPTY_SCOPES = new Set();
13
- // Client-side result budget advertised for tools that do not declare their own
14
- // (fat readers do; see trim.ts). ~50k chars stays well inside a default client
15
- // 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.
16
15
  const DEFAULT_MAX_RESULT_CHARS = 50_000;
17
16
  const DISCOVERY_MODES = ['lazy', 'curated', 'eager'];
18
17
  export function resolveDiscoveryMode(env = process.env) {
@@ -29,9 +28,8 @@ const CUD_OVERRIDES = {
29
28
  drive_transfer: 'create',
30
29
  // read-only resolver; the name's "resolve" verb would otherwise infer update.
31
30
  contacts_resolve: 'read',
32
- // Account management is gated by anthropic/requiresUserInteraction (forced
33
- // human approval), NOT the Google-data write-control profile — so onboarding
34
- // 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.
35
33
  account_add: 'read',
36
34
  account_reauth: 'read',
37
35
  // Writes a LOCAL MCP-client config, not Google data; gated by
@@ -47,10 +45,11 @@ export function serviceOf(name) {
47
45
  }
48
46
  // read tools that write local files — same savePath fanned across accounts would clobber
49
47
  const FANOUT_EXCLUDE = new Set(['gmail_download_attachment', 'drive_download', 'drive_export']);
50
- /** Account-management tools: the `account` they take is the SUBJECT of the
51
- * operation, not the identity it runs as, so injecting the configured default
52
- * would silently retarget them. `account_add` would adopt the default as the
53
- * 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.
54
53
  const DEFAULT_ACCOUNT_EXCLUDE = new Set(['account_add', 'account_reauth']);
55
54
  /** Unwrap optional/default/nullable to the declared scalar kind (zod 4 defs). */
56
55
  function scalarKindOf(field) {
@@ -77,7 +76,7 @@ function isAccountSelector(field) {
77
76
  const def = field?._zod?.def;
78
77
  if (!def)
79
78
  return false;
80
- // A2 made account enums .optional(); unwrap it or fan-out silently dies.
79
+ // Account enums are .optional(); unwrap it or fan-out silently dies.
81
80
  if (def.type === 'optional')
82
81
  return def.innerType?._zod?.def?.type === 'enum';
83
82
  return def.type === 'enum';
@@ -107,8 +106,7 @@ export class ToolRegistry {
107
106
  revealed = new Set();
108
107
  jsonSchemaCache = new Map();
109
108
  argShapeCache = new Map();
110
- /** Memoized isUngrantable verdict per tool: the answer depends on the frozen
111
- * bundle catalog only, so it never changes within a process. */
109
+ // Memoized isUngrantable verdict: depends on the frozen bundle catalog only.
112
110
  ungrantable = new Map();
113
111
  compactOutput = trimEnabled();
114
112
  registeringMeta = false;
@@ -118,9 +116,8 @@ export class ToolRegistry {
118
116
  * lazy surface to curated without touching the configured mode. */
119
117
  expanded = false;
120
118
  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.
119
+ // The registry's OWN account view, so one built over a subset never
120
+ // observes (or leaks) the global set.
124
121
  accounts = getAccountSet) {
125
122
  this.server = server;
126
123
  this.metrics = metrics;
@@ -130,22 +127,17 @@ export class ToolRegistry {
130
127
  this.registerTool = ((name, config, handler) => {
131
128
  const service = serviceOf(name);
132
129
  const cud = config.cud ?? inferCud(name);
133
- // destructiveHint=false claims "additive only" (MCP spec) — updates overwrite, so they stay true.
134
- // idempotent: reads trivially, deletes (already-gone = same), updates
135
- // (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);
136
133
  const annotations = {
137
- readOnlyHint: cud === 'read',
138
- destructiveHint: cud === 'delete' || cud === 'update',
134
+ readOnlyHint: !writes,
135
+ destructiveHint: writes,
139
136
  idempotentHint: cud !== 'create',
140
137
  ...config.annotations,
141
138
  };
142
- // A12: forced per-call human approval on the irreversible set, even in
143
- // bypass mode. Client-enforced via the wire _meta (Claude Code reads
144
- // anthropic/* ONLY there); the server verdict stays separate.
145
- // Every tool also advertises a result-size budget: fat readers declare
146
- // their own, everything else inherits the default, so an uncapped tool
147
- // can never blow past a client's context limit. Generated tools align
148
- // 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.
149
141
  const clientMeta = {
150
142
  'anthropic/maxResultSizeChars': config.cud !== undefined ? MAX_RESPONSE_CHARS : DEFAULT_MAX_RESULT_CHARS,
151
143
  ...config._meta,
@@ -190,10 +182,8 @@ export class ToolRegistry {
190
182
  : writeDisabledResult({ name, service, cud, scopes: config.requiredScopes }, policy,
191
183
  // Concrete by here: default-account injection wraps this.
192
184
  args[0]?.account);
193
- // A2: the ONE default-account injection site — outside the CUD gate and
194
- // the fan-out parse so both observe a concrete alias; NOT meta-skipped
195
- // (that is what covers google_api_call with zero bespoke code). Explicit
196
- // 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).
197
187
  const withDefault = !hasAccountField
198
188
  ? guarded
199
189
  : (...args) => {
@@ -201,9 +191,8 @@ export class ToolRegistry {
201
191
  const value = first?.account;
202
192
  if (value != null && value !== '')
203
193
  return guarded(...args);
204
- // Refresh here or the unset->configured default transition never
205
- // heals for a client that always omits account (this branch never
206
- // 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).
207
196
  refreshAccountSetIfStale();
208
197
  const def = this.accounts().defaultAccount;
209
198
  if (!def) {
@@ -229,10 +218,8 @@ export class ToolRegistry {
229
218
  const finalHandler = this.compactOutput
230
219
  ? async (...args) => compactResult(await withDefault(...args))
231
220
  : withDefault;
232
- // Usage metrics wrap OUTERMOST and only when enabled: off means no
233
- // wrapper exists and the chain is byte-identical to the pre-metrics
234
- // chain. Fan-out width is derived here (bucketed in the module) so the
235
- // 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.
236
223
  const instrumented = this.metrics
237
224
  ? this.metrics.wrap({ name, service, meta: this.registeringMeta, generated: config.cud !== undefined }, finalHandler, (a) => {
238
225
  const v = a?.account;
@@ -301,10 +288,8 @@ export class ToolRegistry {
301
288
  const declared = new Set(Object.keys(self.inputShape));
302
289
  const byKey = new Map();
303
290
  for (const t of this.tools) {
304
- // Only tools the agent can actually see in tools/list. A hint naming a
305
- // registered-but-unadvertised generated tool turns a recoverable error
306
- // into a dead end: in curated mode 353 tools are registered and 181 are
307
- // 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.
308
293
  if (t.service !== self.service || t.name === tool || !this.isVisible(t))
309
294
  continue;
310
295
  for (const key of Object.keys(t.inputShape)) {
@@ -318,9 +303,8 @@ export class ToolRegistry {
318
303
  }
319
304
  }
320
305
  const candidates = [...byKey.entries()].filter(([, e]) => e.tools.length >= 2 || e.curated);
321
- // Reuse the tiered matcher rather than a substring test: the motivating
322
- // case (parentId against parentFolderId) fails containment and edit
323
- // 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.
324
308
  const keys = candidates.map(([key]) => key);
325
309
  const hits = new Set(unknownKeys.slice(0, MAX_SUGGESTED_KEYS).flatMap((k) => suggestKeys(k, keys)));
326
310
  return candidates
@@ -386,7 +370,7 @@ export class ToolRegistry {
386
370
  return true;
387
371
  }
388
372
  /** discover_reset: back to the lean meta-only surface. Clears reveals too.
389
- * 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
390
374
  * no-op for that session — tools stay callable regardless (graceful
391
375
  * dispatch), zero correctness impact. */
392
376
  collapse() {
@@ -416,11 +400,8 @@ export class ToolRegistry {
416
400
  const cached = this.ungrantable.get(tool.name);
417
401
  if (cached !== undefined)
418
402
  return cached;
419
- // EVERY alternative must be ungrantable. `classifyMethodScopes` cannot
420
- // answer this: `add_bundle` and `unknown_scope` share the
421
- // `not_requestable` rank, so its first-best match hides a grantable
422
- // alternative that appears later in the list. Discovery scope lists are
423
- // 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.
424
405
  const verdict = tool.requiredScopes.every((scope) => {
425
406
  const c = classifyScope(scope, EMPTY_SCOPES, EMPTY_SCOPES);
426
407
  return c.state === 'not_requestable' && c.reason === 'unknown_scope';
@@ -430,10 +411,8 @@ export class ToolRegistry {
430
411
  }
431
412
  isVisible(tool) {
432
413
  const mode = this.effectiveMode();
433
- // Advertising a tool that can never succeed spends context budget to hand
434
- // the agent a dead end. It stays registered and callable, so calling it by
435
- // name still produces the real scope error plus its remediation, and
436
- // {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.
437
416
  if (!tool.meta && this.isUngrantable(tool))
438
417
  return false;
439
418
  return (tool.meta ||
@@ -453,10 +432,7 @@ export class ToolRegistry {
453
432
  throw new Error('installListHandler() requires at least one registered tool');
454
433
  }
455
434
  this.server.server.setRequestHandler('tools/list', async () => ({
456
- // The wire Tool is hand-built because the SDK's own types drop the
457
- // anthropic/* _meta keys honoring clients read. z.toJSONSchema emits a
458
- // valid draft-7 object schema by construction, which the SDK's recursive
459
- // 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.
460
436
  tools: this.tools.filter((t) => this.isVisible(t)).map((t) => this.toToolJson(t)),
461
437
  }));
462
438
  }
@@ -474,12 +450,8 @@ export class ToolRegistry {
474
450
  ...(tool.clientMeta ? { _meta: tool.clientMeta } : {}),
475
451
  };
476
452
  }
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). */
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.
483
455
  withLiveAccountEnum(tool, schema) {
484
456
  if (DEFAULT_ACCOUNT_EXCLUDE.has(tool.name))
485
457
  return schema;
@@ -8,6 +8,7 @@ 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>;
@@ -17,6 +18,10 @@ export declare const BUNDLE_ALIASES: Record<string, string>;
17
18
  /** Bundle names are user input: an Object.prototype member ("constructor",
18
19
  * "toString") passes a plain `in` or index lookup, so membership is own-key only. */
19
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[];
20
25
  export declare function resolveBundleAliases(bundles: string[]): string[];
21
26
  /** Closest catalog key for E_UNKNOWN_BUNDLE remediation (edit distance <= 2). */
22
27
  export declare function closestBundle(name: string): string | undefined;
@@ -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',
@@ -165,6 +164,17 @@ const warnedAliases = new Set();
165
164
  export function isKnownBundle(name) {
166
165
  return Object.hasOwn(BUNDLE_CATALOG, name);
167
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
+ }
168
178
  export function resolveBundleAliases(bundles) {
169
179
  return bundles.map((name) => {
170
180
  const target = Object.hasOwn(BUNDLE_ALIASES, name) ? BUNDLE_ALIASES[name] : undefined;
@@ -65,7 +65,7 @@ export interface ScopesReport {
65
65
  bundle: string;
66
66
  }[];
67
67
  /** Runtime-signal bucket: static classification cannot prove account
68
- * type; doctor (B9) fills it from observed persistent admin 403s. */
68
+ * type; doctor fills it from observed persistent admin 403s. */
69
69
  accountType: string[];
70
70
  unknown: string[];
71
71
  };
@@ -73,8 +73,7 @@ export interface ScopesReport {
73
73
  }
74
74
  /**
75
75
  * The account_list / doctor block. Universe = profile ∪ granted ∪ the scopes
76
- * required by REGISTERED tools ("registered but not requestable" is the
77
- * original #114 ask).
76
+ * required by REGISTERED tools ("registered but not requestable" included).
78
77
  */
79
78
  export declare function buildScopesReport(granted: ReadonlySet<string>, profile: ReadonlySet<string>, registeredScopes?: Iterable<string>): ScopesReport;
80
79
  export declare function clearScopeIndexForTest(): void;
@@ -3,9 +3,8 @@ import { BASE_SCOPES, resolveScopesForAccount } from './auth.js';
3
3
  import { readToken } from './token-store.js';
4
4
  import { reauthHint } from './reauth-hint.js';
5
5
  import { hints } from './hint-provider.js';
6
- // Reverse index scope -> bundles offering it, narrowest bundle first (fewest
7
- // scopes) so the remediation names the smallest grant. BASE_SCOPES map to the
8
- // pseudo-bundle "base" (outside a profile only via includesBase:false).
6
+ // Reverse index scope -> bundles offering it, narrowest first, so the
7
+ // remediation names the smallest grant.
9
8
  let reverseIndex = null;
10
9
  function scopeIndex() {
11
10
  if (reverseIndex)
@@ -79,17 +78,14 @@ export function scopeHint(scope, c, alias) {
79
78
  hint: rendered?.hint ??
80
79
  (`Scope ${scope} is in ${alias}'s profile but not granted (you unchecked it at consent). ` +
81
80
  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.
81
+ // The ONE kind whose retriable the host may flip, since the fix needs the user in a browser.
85
82
  retriable: rendered?.retriable ?? true,
86
83
  };
87
84
  }
88
85
  case 'not_requestable':
89
86
  if (c.reason === 'add_bundle') {
90
- // "base" is a pseudo-bundle (BASE_SCOPES excluded by includesBase:
91
- // false), NOT a catalog key — telling users to add it to bundles[]
92
- // 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.
93
89
  if (c.bundle === 'base') {
94
90
  const rendered = hints()?.render({ kind: 'scope_base_excluded', account: alias, scope });
95
91
  return {
@@ -153,9 +149,8 @@ export function scopeHintForMethod(required, alias, deps = {}) {
153
149
  if (!best || best.state === 'callable')
154
150
  return null; // 403 despite grants: not a scope-classification problem
155
151
  const base = scopeHint(best.scope, best.classification, alias);
156
- // Hedged, never fabricated: an in-profile admin scope that 403s is EITHER a
157
- // Workspace account needing re-auth OR a personal account that can never be
158
- // 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.
159
154
  if (best.state === 'requestable_not_granted' && ADMIN_SCOPES.includes(best.scope)) {
160
155
  return {
161
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.`,
@@ -166,8 +161,7 @@ export function scopeHintForMethod(required, alias, deps = {}) {
166
161
  }
167
162
  /**
168
163
  * The account_list / doctor block. Universe = profile ∪ granted ∪ the scopes
169
- * required by REGISTERED tools ("registered but not requestable" is the
170
- * original #114 ask).
164
+ * required by REGISTERED tools ("registered but not requestable" included).
171
165
  */
172
166
  export function buildScopesReport(granted, profile, registeredScopes = []) {
173
167
  const callable = [];
@@ -14,6 +14,9 @@ export declare const GENERATED_GATES: Record<string, {
14
14
  enabled: (set?: AccountSet) => boolean;
15
15
  hint: string;
16
16
  }>;
17
+ /** Message for a `tools/call` naming a tool that is not registered: the SDK's
18
+ * bare "Tool X not found" reads identically for a typo, a gated service, and
19
+ * an unknown API, but the server knows which is which from its boot-time enabled-service list. */
17
20
  export declare function unknownToolMessage(registry: ToolRegistry, rawName: string): string;
18
21
  /**
19
22
  * Gated services the registry's CURRENT accounts enable but that registered
package/dist/services.js CHANGED
@@ -57,20 +57,15 @@ export const GENERATED_GATES = {
57
57
  script: bundleGate('script'),
58
58
  vault: bundleGate('vault'),
59
59
  };
60
- /**
61
- * Message for a `tools/call` naming a tool that is not registered. The SDK's
62
- * own answer is a bare "Tool X not found", which reads identically for a typo,
63
- * for a service gated behind a scope bundle, and for an API this server has
64
- * never heard of. Those need different next steps, and the server knows which
65
- * is which: it composed the enabled-service list at boot.
66
- */
67
60
  const MAX_TOOL_NAME_ECHO = 64;
61
+ /** Message for a `tools/call` naming a tool that is not registered: the SDK's
62
+ * bare "Tool X not found" reads identically for a typo, a gated service, and
63
+ * an unknown API, but the server knows which is which from its boot-time enabled-service list. */
68
64
  export function unknownToolMessage(registry, rawName) {
69
65
  // The name is the caller's, unvalidated: echo a bounded prefix only.
70
66
  const name = rawName.length > MAX_TOOL_NAME_ECHO ? `${sliceClean(rawName, MAX_TOOL_NAME_ECHO)}...` : rawName;
71
- // A service that EXISTS in the build but registered nothing is off, not
72
- // misspelled. Checked before the did-you-mean, which would otherwise point
73
- // chat_spaces_get at meet_spaces_get.
67
+ // A service that EXISTS but registered nothing is off, not misspelled;
68
+ // checked before the did-you-mean, which would otherwise point chat_spaces_get at meet_spaces_get.
74
69
  const service = serviceOf(name);
75
70
  const known = SERVICES.some((s) => s.name === service) || GENERATED_SERVICES.some((s) => s.name === service);
76
71
  if (known && !registry.services().includes(service)) {
@@ -1,9 +1,5 @@
1
- // B13 / C11: SSRF guard for every server-side fetch (the CIMD client_id doc).
2
- // D4 decision (dependencies-and-bv §2.2): a small AUDITED private-range check,
3
- // no dependency — HTTPS-only, resolve-then-check ALL addresses (anti-rebind),
4
- // no redirects followed, and capped size and time. Blocks 10/8, 172.16/12,
5
- // 192.168/16, 127/8, 169.254/16, 0/8, multicast, ::1, fc00::/7, fe80::/10, and
6
- // v4-mapped forms.
1
+ // SSRF guard for every server-side fetch (the CIMD client_id doc): HTTPS-only,
2
+ // resolve-then-check ALL addresses (anti-rebind), no redirects, capped size/time.
7
3
  import { lookup } from 'node:dns/promises';
8
4
  import { isIPv4, isIPv6, BlockList } from 'node:net';
9
5
  export class SsrfBlockedError extends Error {
@@ -46,10 +42,8 @@ export function isBlockedIPv4(ip) {
46
42
  return true; // 224/4 multicast + 240/4 reserved
47
43
  return false;
48
44
  }
49
- // Built-in subnet math (no hand-rolled IPv6 parsing): blocks loopback,
50
- // unspecified, ULA, link-local, multicast, and ALL v4-mapped / v4-compatible /
51
- // NAT64 / 6to4 embeddings (a public host never resolves to those, so blocking
52
- // the whole range closes the embedded-private-target gap without parsing).
45
+ // Built-in subnet math blocks v4-mapped/NAT64/6to4 embeddings too: a public
46
+ // host never resolves to those, so the whole range closes the embedding gap.
53
47
  const V6_BLOCKED = new BlockList();
54
48
  V6_BLOCKED.addSubnet('::', 96, 'ipv6'); // ::/96 = ::1, ::, and v4-compatible
55
49
  V6_BLOCKED.addSubnet('::ffff:0:0', 96, 'ipv6'); // v4-mapped
@@ -145,10 +139,8 @@ export async function assertPublicHttpsUrl(rawUrl, deps = {}) {
145
139
  }
146
140
  return { url, addresses };
147
141
  }
148
- // Connection-level failures that a retry can legitimately recover from. A dead
149
- // IPv6 route (broken egress with Happy-Eyeballs falling through), a reset, or a
150
- // DNS blip are transient; an SSRF block, a bad HTTP status, or malformed JSON
151
- // are deterministic and must NOT be retried.
142
+ // Connection-level failures a retry can recover from; an SSRF block, a bad
143
+ // HTTP status, or malformed JSON are deterministic and must NOT be retried.
152
144
  const TRANSIENT_CODES = new Set([
153
145
  'ETIMEDOUT',
154
146
  'ECONNRESET',
@@ -170,8 +162,7 @@ export function isTransientFetchError(e) {
170
162
  const code = cause?.code ?? e?.code;
171
163
  if (code && TRANSIENT_CODES.has(code))
172
164
  return true;
173
- // undici surfaces connection failures as TypeError('fetch failed') with the
174
- // real reason in .cause; retry even when the cause carries no useful code.
165
+ // undici's TypeError('fetch failed') retries even with no useful .cause code.
175
166
  if (e instanceof TypeError && /fetch failed/i.test(e.message))
176
167
  return true;
177
168
  return false;
@@ -197,9 +188,8 @@ export async function fetchCimdDocument(rawUrl, opts = {}) {
197
188
  const backoffMs = opts.retryBackoffMs ?? 200;
198
189
  const doFetch = opts.fetchImpl ?? fetch;
199
190
  const sleep = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
200
- // Retry fast transient failures IN PLACE. Each attempt re-runs the
201
- // resolve-then-check so a retry can never skip the anti-rebind guard, and
202
- // the timer is per-attempt (armed across the lookup and the body read, #10).
191
+ // Each attempt re-runs resolve-then-check so a retry can't skip the
192
+ // anti-rebind guard; the timer is per-attempt, armed across lookup and body read.
203
193
  for (let attempt = 0;; attempt++) {
204
194
  const controller = new AbortController();
205
195
  let timedOut = false;
@@ -247,9 +237,8 @@ export async function fetchCimdDocument(rawUrl, opts = {}) {
247
237
  }
248
238
  }
249
239
  }
250
- /** Read a response body, aborting the moment it exceeds maxBytes (never buffers
251
- * an oversized body). Falls back to a capped arrayBuffer when there is no
252
- * readable stream (e.g. a test Response). */
240
+ // Aborts the moment maxBytes is exceeded (never buffers an oversized body);
241
+ // falls back to a capped arrayBuffer when there's no readable stream (e.g. a test Response).
253
242
  async function readCapped(res, maxBytes, controller) {
254
243
  const reader = res.body?.getReader?.();
255
244
  if (!reader) {
@@ -1,7 +1,5 @@
1
- // S1.21: tenant offboarding primitives. Core owns the reusable token-purge
2
- // legs (generically useful, tenant-id-agnostic); the tenant_delete
3
- // ORCHESTRATION (what to purge, in what order, from which admin surface)
4
- // lives with the tenancy module that calls these.
1
+ // Tenant offboarding primitives: core owns the reusable token-purge legs;
2
+ // orchestration (what to purge, in what order) lives with the tenancy module calling these.
5
3
  import fs from 'node:fs';
6
4
  import path from 'node:path';
7
5
  import { tenantConfigFilePath, tenantTokenDir } from './config-file.js';
Binary file
@@ -1,9 +1,9 @@
1
1
  import { z } from 'zod';
2
- export declare function coerceArray<T extends z.ZodTypeAny>(element: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<T>>;
3
- export declare function coerceJson<T extends z.ZodTypeAny>(schema: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, T>;
2
+ export declare function coerceArray<T extends z.ZodTypeAny>(element: T): z.ZodPreprocess<z.ZodArray<T>, unknown>;
3
+ export declare function coerceJson<T extends z.ZodTypeAny>(schema: T): z.ZodPreprocess<T, unknown>;
4
4
  /** Numeric args arrive string-encoded from some clients. Arg normalization
5
5
  * runs on BOTH transports, but it only coerces keys it RENAMED, so a value
6
6
  * sent under the correct key never passes through it and the coercion has to
7
7
  * live in the schema. */
8
- export declare function coerceNumber<T extends z.ZodTypeAny>(schema: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, T>;
9
- export declare const coerceBoolean: z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodBoolean>;
8
+ export declare function coerceNumber<T extends z.ZodTypeAny>(schema: T): z.ZodPreprocess<T, unknown>;
9
+ export declare const coerceBoolean: z.ZodPreprocess<z.ZodBoolean, unknown>;