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

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 (126) hide show
  1. package/README.md +20 -6
  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 +6 -12
  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 +104 -22
  26. package/dist/hint-provider.d.ts +4 -1
  27. package/dist/hint-provider.js +4 -10
  28. package/dist/http-config.d.ts +2 -2
  29. package/dist/http-config.js +3 -7
  30. package/dist/http-transport.d.ts +5 -5
  31. package/dist/http-transport.js +39 -92
  32. package/dist/identity.d.ts +2 -2
  33. package/dist/identity.js +6 -9
  34. package/dist/index.js +19 -31
  35. package/dist/master-key.d.ts +3 -3
  36. package/dist/master-key.js +11 -22
  37. package/dist/mcp-token.d.ts +40 -16
  38. package/dist/mcp-token.js +121 -44
  39. package/dist/metrics-cli.d.ts +1 -1
  40. package/dist/metrics-cli.js +1 -1
  41. package/dist/metrics-tap.js +2 -5
  42. package/dist/migrate-config.d.ts +1 -2
  43. package/dist/migrate-config.js +3 -5
  44. package/dist/net-tuning.js +2 -4
  45. package/dist/oauth-as.d.ts +7 -2
  46. package/dist/oauth-as.js +45 -78
  47. package/dist/oauth-consent.js +4 -10
  48. package/dist/outbound-allowlist.js +4 -9
  49. package/dist/reauth-hint.js +2 -5
  50. package/dist/registry-transfer.d.ts +1 -1
  51. package/dist/registry-transfer.js +5 -13
  52. package/dist/registry.d.ts +3 -9
  53. package/dist/registry.js +97 -76
  54. package/dist/scope-catalog.d.ts +5 -0
  55. package/dist/scope-catalog.js +17 -7
  56. package/dist/scope-observability.d.ts +2 -3
  57. package/dist/scope-observability.js +8 -14
  58. package/dist/services.d.ts +3 -0
  59. package/dist/services.js +5 -10
  60. package/dist/ssrf-guard.js +11 -22
  61. package/dist/tenant-purge.js +2 -4
  62. package/dist/token-store.js +0 -0
  63. package/dist/tools/_coerce.d.ts +4 -4
  64. package/dist/tools/_errors.js +22 -39
  65. package/dist/tools/_local-files.js +4 -8
  66. package/dist/tools/account-wizard.d.ts +4 -3
  67. package/dist/tools/account-wizard.js +48 -54
  68. package/dist/tools/accounts-tool.d.ts +8 -1
  69. package/dist/tools/accounts-tool.js +18 -7
  70. package/dist/tools/admin.js +8 -4
  71. package/dist/tools/analytics.js +32 -16
  72. package/dist/tools/calendar.d.ts +5 -0
  73. package/dist/tools/calendar.js +63 -7
  74. package/dist/tools/chat.js +2 -3
  75. package/dist/tools/contacts.js +19 -14
  76. package/dist/tools/docs.js +45 -21
  77. package/dist/tools/drive.js +72 -39
  78. package/dist/tools/forms.js +2 -3
  79. package/dist/tools/generated/gmail.js +0 -14
  80. package/dist/tools/generated/method-map.js +1 -1
  81. package/dist/tools/gmail-mime.d.ts +1 -1
  82. package/dist/tools/gmail-mime.js +7 -12
  83. package/dist/tools/gmail.d.ts +3 -3
  84. package/dist/tools/gmail.js +135 -52
  85. package/dist/tools/google-api.js +5 -7
  86. package/dist/tools/meet.js +2 -3
  87. package/dist/tools/searchconsole.js +2 -3
  88. package/dist/tools/sheets.d.ts +10 -0
  89. package/dist/tools/sheets.js +111 -5
  90. package/dist/tools/slides.js +2 -3
  91. package/dist/tools/tasks.js +2 -3
  92. package/dist/trim.js +2 -3
  93. package/dist/usage-metrics.js +5 -12
  94. package/dist/write-control.d.ts +4 -8
  95. package/dist/write-control.js +18 -6
  96. package/package.json +24 -22
  97. package/skills/google-workspace/SKILL.md +162 -0
  98. package/skills/google-workspace/reference/admin.md +105 -0
  99. package/skills/google-workspace/reference/analytics.md +70 -0
  100. package/skills/google-workspace/reference/appsmarket.md +5 -0
  101. package/skills/google-workspace/reference/calendar.md +175 -0
  102. package/skills/google-workspace/reference/chat.md +59 -0
  103. package/skills/google-workspace/reference/classroom.md +5 -0
  104. package/skills/google-workspace/reference/cloudidentity.md +5 -0
  105. package/skills/google-workspace/reference/cloudsearch.md +5 -0
  106. package/skills/google-workspace/reference/contacts.md +138 -0
  107. package/skills/google-workspace/reference/docs.md +395 -0
  108. package/skills/google-workspace/reference/drive.md +485 -0
  109. package/skills/google-workspace/reference/driveactivity.md +5 -0
  110. package/skills/google-workspace/reference/drivelabels.md +5 -0
  111. package/skills/google-workspace/reference/forms.md +92 -0
  112. package/skills/google-workspace/reference/gmail.md +305 -0
  113. package/skills/google-workspace/reference/groupsmigration.md +5 -0
  114. package/skills/google-workspace/reference/groupssettings.md +5 -0
  115. package/skills/google-workspace/reference/keep.md +5 -0
  116. package/skills/google-workspace/reference/licensing.md +5 -0
  117. package/skills/google-workspace/reference/meet.md +68 -0
  118. package/skills/google-workspace/reference/postmaster.md +5 -0
  119. package/skills/google-workspace/reference/reseller.md +5 -0
  120. package/skills/google-workspace/reference/script.md +5 -0
  121. package/skills/google-workspace/reference/searchconsole.md +128 -0
  122. package/skills/google-workspace/reference/sheets.md +430 -0
  123. package/skills/google-workspace/reference/slides.md +67 -0
  124. package/skills/google-workspace/reference/tasks.md +167 -0
  125. package/skills/google-workspace/reference/vault.md +5 -0
  126. package/skills/google-workspace/reference/workspaceevents.md +5 -0
package/README.md CHANGED
@@ -4,7 +4,7 @@ The most complete **local Google Workspace MCP server**: Gmail, Drive, Calendar,
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/mcp-google-multi?label=npm&color=cb3837)](https://www.npmjs.com/package/mcp-google-multi)
6
6
 
7
- - 🧰 **Exhaustive** — 940 tools across 29 services, now including Google Analytics (GA4), + an escape hatch for anything else → [COVERAGE.md](./COVERAGE.md)
7
+ - 🧰 **Exhaustive** — 942 tools across 29 services, now including Google Analytics (GA4), + an escape hatch for anything else → [COVERAGE.md](./COVERAGE.md)
8
8
  - 🔑 **Multi-account** — drive any number of Google accounts by alias, or fan one call out across all of them
9
9
  - 🔒 **Private by design** — your own OAuth app, tokens encrypted at rest (AES-256-GCM), writes deny-by-default, no telemetry, no metering — it talks only to Google
10
10
  - 🌐 **Local or remote** — runs locally over stdio, or self-hosted over HTTP with its own built-in OAuth 2.1 server (Claude Code's `/mcp` login and the claude.ai connector, zero custom UI). Pull-and-up Docker Compose with optional automatic HTTPS → [remote setup](./docs/http-setup.md)
@@ -44,7 +44,7 @@ New to all this? It's written for someone who just installed Claude Code and has
44
44
  5. **Add it to Claude Code, then restart Claude Code:**
45
45
 
46
46
  ```bash
47
- claude mcp add google-multi -s user -- npx -y mcp-google-multi
47
+ claude mcp add mcp-google-multi -s user -- npx -y mcp-google-multi
48
48
  ```
49
49
 
50
50
  **Stuck at any point? Run `mcp-google-multi doctor`.** It inspects every part and prints the exact fix for anything wrong (a missing sign-in, a Google API you still need to switch on, and so on). Once it reads all-green, just talk to Claude: *"summarize my unread email."*
@@ -55,14 +55,28 @@ New to all this? It's written for someone who just installed Claude Code and has
55
55
 
56
56
  **Go deeper:** [Configuration reference](./docs/configuration.md) · [What's covered](./COVERAGE.md) · [Features tour](./docs/features.md) · [Remote / HTTP setup](./docs/http-setup.md) · [Secrets in a vault](./docs/secrets.md) · [Local usage metrics](./docs/usage-metrics.md) · [Migrating to v6](./MIGRATION-v6.md) · [Security policy](./SECURITY.md) · [Roadmap](https://github.com/bakissation/mcp-google-multi/milestones)
57
57
 
58
- ## Maintainer & credits
58
+ ## Agent skill
59
+
60
+ The repo and the npm package both ship an [Agent Skill](https://code.claude.com/docs/en/skills) at `skills/google-workspace/`: a hand-written `SKILL.md` covering account selection, the discover step, incremental Gmail sweeps, keeping results small, write profiles, and the escape hatch, plus a `reference/` folder with a generated argument table per service. Loading it gets an agent calling this server's tools correctly without guessing argument names.
61
+
62
+ To install it for Claude Code, copy or symlink the `skills/google-workspace` folder into `~/.claude/skills/` (every project) or a project's own `.claude/skills/` (that project only):
63
+
64
+ ```bash
65
+ # from a cloned copy of this repo
66
+ ln -s "$(pwd)/skills/google-workspace" ~/.claude/skills/google-workspace
67
+
68
+ # from the installed npm package
69
+ ln -s "$(npm root -g)/mcp-google-multi/skills/google-workspace" ~/.claude/skills/google-workspace
70
+ ```
71
+
72
+ ## Maintainer, sponsor & license
59
73
 
60
74
  Built and maintained by **Abdelbaki Berkati** — [berkati.xyz](https://berkati.xyz) · [@bakissation](https://github.com/bakissation). [Read the case study →](https://berkati.xyz/case-studies/mcp-google-multi/)
61
75
 
62
76
  Development is **funded by [IdeaCrafters](https://ideacrafters.com)** ([@IdeaCraftersHQ](https://github.com/IdeaCraftersHQ)) — the studio that pays for this OSS to exist.
63
77
 
64
- Thanks to contributors [@obatried](https://github.com/obatried), [@trevor-commits](https://github.com/trevor-commits), and [@mjreddy](https://github.com/mjreddy). The project is maintainer-led (roadmap on [Milestones](https://github.com/bakissation/mcp-google-multi/milestones); bug reports welcome, feature PRs by prior agreement — see [CONTRIBUTING.md](./CONTRIBUTING.md)). **Feedback shapes the roadmap: [open an issue](https://github.com/bakissation/mcp-google-multi/issues/new/choose) with bugs, pain points, or what you wish it did.** Security reports go to [SECURITY.md](./SECURITY.md), never a public issue.
78
+ An Enterprise Edition (hosted, multi-tenant) is available: ask through [berkati.xyz](https://berkati.xyz) (email or LinkedIn there).
65
79
 
66
- ## License
80
+ Thanks to contributors [@obatried](https://github.com/obatried), [@trevor-commits](https://github.com/trevor-commits), and [@mjreddy](https://github.com/mjreddy). The project is maintainer-led (roadmap on [Milestones](https://github.com/bakissation/mcp-google-multi/milestones); bug reports welcome, feature PRs by prior agreement — see [CONTRIBUTING.md](./CONTRIBUTING.md)). **Feedback shapes the roadmap: [open an issue](https://github.com/bakissation/mcp-google-multi/issues/new/choose) with bugs, pain points, or what you wish it did.** Security reports go to [SECURITY.md](./SECURITY.md), never a public issue.
67
81
 
68
- [MIT](./LICENSE)
82
+ Licensed under [MIT](./LICENSE).
@@ -28,7 +28,7 @@ export declare function getAccountSet(): AccountSet;
28
28
  /** Re-resolve after a config.json mutation and swap the live set. */
29
29
  export declare function invalidateAccountSet(): AccountSet;
30
30
  export declare function refreshAccountSetIfStale(): void;
31
- /** Cross-process staleness probe (BR-7): one stat, compared against the stamp. */
31
+ /** Cross-process staleness probe: one stat, compared against the stamp. */
32
32
  export declare function isAccountSetStale(): boolean;
33
33
  /** Account aliases (possibly empty on a fresh install).
34
34
  * Snapshot from the initial load; enums widen only when the registry is
@@ -43,7 +43,7 @@ export declare function unknownAliasMessage(aliases: readonly string[], selector
43
43
  * The `account` param schema, empty-registry-safe. A `z.enum` requires at least
44
44
  * one value, so a fresh install (zero aliases) would throw at schema-build time
45
45
  * (module load) and take down every CLI — including `doctor`, which is meant to
46
- * REPORT the empty registry (gap #23). Fall back to a plain string when there
46
+ * REPORT the empty registry. Fall back to a plain string when there
47
47
  * are no aliases: no alias exists to enumerate, the server refuses to boot empty
48
48
  * anyway, and dispatch validates the account against the live set. */
49
49
  export declare function accountAliasSchemaFor(aliases: readonly string[]): z.ZodType<string>;
@@ -62,7 +62,7 @@ export declare function liveAccountCheck(aliases: () => readonly string[], selec
62
62
  /** True for a field built on liveAccountCheck, optional or not. */
63
63
  export declare function isLiveAccountField(field: unknown): boolean;
64
64
  /**
65
- * BR-4: the stdio/http SERVER never boots with an empty registry — a fresh user
65
+ * The stdio/http SERVER never boots with an empty registry — a fresh user
66
66
  * bootstraps via env / `migrate-config` / `account import` / `auth` first. The
67
67
  * bootstrap and diagnostic CLIs return before this guard, so it gates only the
68
68
  * server path (called from index.ts after the CLI branches). */
package/dist/accounts.js CHANGED
@@ -67,7 +67,7 @@ function noAccountsMessage() {
67
67
  `or set GOOGLE_ACCOUNTS=work:user@company.com,personal:user@gmail.com in the environment.${envHint}`);
68
68
  }
69
69
  /**
70
- * Registry resolution (BR-2): a non-empty GOOGLE_ACCOUNTS env takes the WHOLE
70
+ * Registry resolution: a non-empty GOOGLE_ACCOUNTS env takes the WHOLE
71
71
  * registry from env (12-factor override, never merged key-by-key); otherwise
72
72
  * the registry comes from config.json. GOOGLE_ADMIN_ACCOUNTS, when NON-EMPTY,
73
73
  * overrides per-account admin flags from the file (env var > config field);
@@ -75,8 +75,7 @@ function noAccountsMessage() {
75
75
  */
76
76
  let warnedLegacy = false;
77
77
  export function resolveAccounts(env = process.env, filePath = configFilePath(), onInvalid = 'exit',
78
- // emptyOk: zero accounts is a valid set, not an error, even with 'throw'
79
- // (a context that has linked nothing yet). An invalid file still fails.
78
+ // emptyOk: zero accounts is a valid set, not an error, even with 'throw'.
80
79
  opts = {}) {
81
80
  const dir = opts.tokenDir ?? tokenDir;
82
81
  const adminEnv = parseCsv(env.GOOGLE_ADMIN_ACCOUNTS);
@@ -86,9 +85,8 @@ opts = {}) {
86
85
  throw new Error(`${slug}: ${message}`);
87
86
  failStartup(slug, message);
88
87
  };
89
- // Validate the legacy global override AT BOOT, whatever GOOGLE_TOOLSETS
90
- // selects — otherwise a typo only surfaces at dispatch time inside
91
- // account_list instead of the promised startup error (BR3).
88
+ // Validate at BOOT, or a typo only surfaces at dispatch time inside
89
+ // account_list instead of the promised startup error.
92
90
  const legacyNames = parseCsv(env.GOOGLE_OPTIONAL_SCOPES);
93
91
  if (legacyNames.length > 0) {
94
92
  for (const bundle of resolveBundleAliases(legacyNames)) {
@@ -120,19 +118,13 @@ opts = {}) {
120
118
  ...def,
121
119
  };
122
120
  }
123
- // Stat BEFORE read: a cross-process write landing between the two makes the
124
- // stamp conservative (flags stale again next dispatch) instead of pinning
125
- // stale content behind a fresh stamp.
121
+ // Stat BEFORE read, so a race lands on the conservative (stale) side.
126
122
  const preStamp = fileStamp(filePath, CONFIG_VERSION);
127
123
  const config = loadConfigFile(filePath, onInvalid);
128
124
  const entries = Object.entries(config?.accounts ?? {});
129
125
  if (entries.length === 0) {
130
- // Gap #23: an empty registry is NOT fatal at resolve time — the bootstrap and
131
- // diagnostic CLIs (doctor/reset/account import/migrate-config/config check)
132
- // must run on a fresh install. Only the stdio/http SERVER refuses to boot
133
- // empty (BR-4), enforced by assertServerAccountsConfigured() in index.ts.
134
- // The dispatch-path reload ('throw') still throws so a mid-session emptied
135
- // config.json keeps the last-good registry (BR-7) instead of dropping tools.
126
+ // Not fatal here: bootstrap/diagnostic CLIs must run on a fresh install;
127
+ // only the server refuses to boot empty (index.ts).
136
128
  if (onInvalid === 'throw' && !opts.emptyOk) {
137
129
  throw new Error(`E_NO_ACCOUNTS_CONFIGURED: ${noAccountsMessage()}`);
138
130
  }
@@ -144,9 +136,8 @@ opts = {}) {
144
136
  stamp: `${config?.version ?? CONFIG_VERSION}:${preStamp.split(':')[1]}`,
145
137
  };
146
138
  }
147
- // BR3: unknown bundle names fail loudly (a mis-scoped token is worse than a
148
- // clear error); v5 silently filtered them. Null prototype: profile names are
149
- // user input and must never collide with Object.prototype members.
139
+ // Unknown bundle names fail loudly (a mis-scoped token is worse than a clear
140
+ // error). Null prototype: profile names are user input, must never collide with Object.prototype.
150
141
  const scopeProfiles = Object.create(null);
151
142
  scopeProfiles.base = { bundles: [] };
152
143
  for (const [name, profile] of Object.entries(config?.scopeProfiles ?? {})) {
@@ -187,9 +178,8 @@ opts = {}) {
187
178
  ...def,
188
179
  };
189
180
  }
190
- /** A2: env GOOGLE_DEFAULT_ACCOUNT > config.defaultAccount > unset. A configured
191
- * default naming an unknown alias refuses to start (E_DEFAULT_ACCOUNT_UNKNOWN —
192
- * deliberately NOT E_CONFIG_INVALID: the config is schema-valid). */
181
+ // env GOOGLE_DEFAULT_ACCOUNT > config.defaultAccount > unset; an unknown alias
182
+ // refuses to start as E_DEFAULT_ACCOUNT_UNKNOWN, not E_CONFIG_INVALID (the config is schema-valid).
193
183
  function resolveDefaultAccount(env, fromConfig, aliases, fail) {
194
184
  const fromEnv = env.GOOGLE_DEFAULT_ACCOUNT?.trim();
195
185
  const value = fromEnv || fromConfig || undefined;
@@ -208,17 +198,14 @@ export function fileStamp(filePath, version) {
208
198
  return `${version}:0`;
209
199
  }
210
200
  }
211
- // First-run shim (BC6): env is set and no config.json exists yet — materialize
212
- // the file so the wizard has something to edit. Env still wins this session;
213
- // a write failure must never block boot (warn on stderr and continue).
201
+ // env is set and no config.json exists yet: materialize the file so the
202
+ // wizard has something to edit; a write failure must never block boot.
214
203
  function materializeFirstRun(aliases, configs, filePath) {
215
204
  if (fs.existsSync(filePath))
216
205
  return;
217
206
  try {
218
207
  let wrote = false;
219
- // mutateConfigFile = lock + re-check + atomic write, so a concurrent
220
- // wizard/migrate writer is never clobbered (the loaded `current` is
221
- // re-read under the lock; only a still-absent file gets the env content).
208
+ // lock + re-check + atomic write, so a concurrent wizard/migrate writer is never clobbered.
222
209
  mutateConfigFile((current) => {
223
210
  if (Object.keys(current.accounts ?? {}).length > 0)
224
211
  return current;
@@ -251,7 +238,7 @@ export function invalidateAccountSet() {
251
238
  return current;
252
239
  }
253
240
  /**
254
- * Dispatch-path reload (BR-7): NEVER exits and never throws — a mid-edit,
241
+ * Dispatch-path reload: NEVER exits and never throws — a mid-edit,
255
242
  * corrupt, or deleted config.json keeps the last-good set and warns once per
256
243
  * distinct failure on stderr. failStartup semantics are boot/CLI-only.
257
244
  */
@@ -271,7 +258,7 @@ export function refreshAccountSetIfStale() {
271
258
  }
272
259
  }
273
260
  }
274
- /** Cross-process staleness probe (BR-7): one stat, compared against the stamp. */
261
+ /** Cross-process staleness probe: one stat, compared against the stamp. */
275
262
  export function isAccountSetStale() {
276
263
  if (current.source !== 'file')
277
264
  return false;
@@ -297,7 +284,7 @@ export function unknownAliasMessage(aliases, selectors = false) {
297
284
  * The `account` param schema, empty-registry-safe. A `z.enum` requires at least
298
285
  * one value, so a fresh install (zero aliases) would throw at schema-build time
299
286
  * (module load) and take down every CLI — including `doctor`, which is meant to
300
- * REPORT the empty registry (gap #23). Fall back to a plain string when there
287
+ * REPORT the empty registry. Fall back to a plain string when there
301
288
  * are no aliases: no alias exists to enumerate, the server refuses to boot empty
302
289
  * anyway, and dispatch validates the account against the live set. */
303
290
  export function accountAliasSchemaFor(aliases) {
@@ -339,7 +326,7 @@ export function isLiveAccountField(field) {
339
326
  return def?.checks?.some((c) => LIVE_ACCOUNT_CHECKS.has(c)) ?? false;
340
327
  }
341
328
  /**
342
- * BR-4: the stdio/http SERVER never boots with an empty registry — a fresh user
329
+ * The stdio/http SERVER never boots with an empty registry — a fresh user
343
330
  * bootstraps via env / `migrate-config` / `account import` / `auth` first. The
344
331
  * bootstrap and diagnostic CLIs return before this guard, so it gates only the
345
332
  * server path (called from index.ts after the CLI branches). */
@@ -1,10 +1,7 @@
1
1
  import { echoName, echoNames, screenArguments, unknownArgEnvelope, } from './arg-strict.js';
2
2
  import { logSafe, sliceClean } from './trim.js';
3
- // The SDK synthesizes input-validation failures as isError RESULTS, skipping
4
- // the handler entirely: no handler of ours runs, so no envelope exists and the
5
- // client gets free text. The text is prose in both SDK eras (v1 prefixed it
6
- // with "MCP error -32602: ", v2 dropped that prefix), while handler envelopes
7
- // are always JSON starting with "{".
3
+ // The SDK synthesizes input-validation failures as isError RESULTS (no handler
4
+ // runs), so the client gets free prose; handler envelopes always start with "{".
8
5
  const SCHEMA_VALIDATION_TEXT = /^(MCP error -32602: )?Input validation error:/;
9
6
  export function isSchemaValidationText(text) {
10
7
  return SCHEMA_VALIDATION_TEXT.test(text);
@@ -12,18 +9,11 @@ export function isSchemaValidationText(text) {
12
9
  /** Capture form of the v2 text. The tool-name class excludes ':' and ' ', so
13
10
  * the first ": " after the name is unambiguously the delimiter. */
14
11
  const VALIDATION_PROSE = /^(?:MCP error -32602: )?Input validation error: Invalid arguments for tool ([A-Za-z0-9_.-]+): ([\s\S]+)$/;
15
- /** The SDK's OTHER synthesized prose on the same catch. Both prefixes are
16
- * SDK-authored; a HANDLER's free text is not, and must survive untouched
17
- * (the account wizard answers in prose today). */
12
+ /** The SDK's OTHER synthesized prose on the same catch; a HANDLER's free text
13
+ * is not SDK-authored and must survive untouched. */
18
14
  const SDK_OUTPUT_PROSE = /^(?:MCP error -32602: )?Output validation error: /;
19
- // Wire-level tools/call argument normalization. Clients (LLMs) recurringly
20
- // snake_case a camelCase parameter (thread_id for threadId) and burn a retry
21
- // on the -32602. A schema-level fix is off the table: the SDK advertises an
22
- // EMPTY input schema for any non-object wrapper (pipe/preprocess), so the
23
- // only seam that keeps tools/list intact is the JSON-RPC message itself —
24
- // which is versioned MCP spec, stabler than any SDK internal. The rename is
25
- // provably lossless: it fires only when the sent key is NOT in the tool's
26
- // schema, its camelCase twin IS, and that twin was not also sent.
15
+ // Wire-level, not schema-level: the SDK advertises an EMPTY input schema for
16
+ // any non-object wrapper, so the JSON-RPC message is the only stable seam. See docs/internals.md.
27
17
  export function argNormalizationEnabled(env = process.env) {
28
18
  return !/^(0|false|off|no)$/i.test((env.GOOGLE_ARG_NORMALIZE ?? '').trim());
29
19
  }
@@ -94,11 +84,8 @@ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}
94
84
  return { action: 'forward', msg };
95
85
  const tool = m.params.name;
96
86
  const declared = opts.declaredFor(tool);
97
- // Unregistered tool. The spec keeps this a PROTOCOL error (unknown tool is
98
- // not something the model can fix by adjusting arguments), so the channel
99
- // stays a JSON-RPC error; only the message gets the did-you-mean and the
100
- // gated-service next step. Without `unknownTool`, or on a notification,
101
- // the SDK's own "not found" still answers.
87
+ // Unregistered tool stays a PROTOCOL error (not an arguments problem); only
88
+ // the message gets the did-you-mean. No `unknownTool`, or a notification: the SDK's own answers.
102
89
  if (!declared) {
103
90
  if (!opts.unknownTool || m.id === undefined)
104
91
  return { action: 'forward', msg };
@@ -115,8 +102,7 @@ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}
115
102
  if (screened.unknown.length === 0 && screened.redundant.length === 0)
116
103
  return { action: 'forward', msg };
117
104
  const all = [...screened.unknown.map((u) => u.sent), ...screened.redundant];
118
- // Key names only; argument VALUES never reach the log. The names are the
119
- // caller's, so they are escaped here and only here: the envelope is JSON.
105
+ // Key names only, escaped here and only here: the caller's names, never values, reach the log.
120
106
  log(`[args] ${tool}: undeclared ${logSafe(echoNames(all), 600)}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
121
107
  try {
122
108
  // Only ever a DECLARED key or the literal placeholder, so the metrics
@@ -167,13 +153,10 @@ export function withArgNormalization(transport, shapeFor, log, onRename, strict)
167
153
  if (outcome.action === 'forward')
168
154
  return handler(outcome.msg, extra);
169
155
  // Last-resort fallback: dispatch as before rather than hang the
170
- // caller. The stderr line and the counter already fired above.
171
- // `send` is async, so a rejected promise needs catching too: a
172
- // bare `void` left the client waiting for a frame that never came.
156
+ // caller; send is async, so a bare `void` would leave it waiting forever on rejection.
173
157
  const fallback = () => handler(normalized, extra);
174
158
  try {
175
- // Answer on the INNER transport so the metrics tap still sees the
176
- // frame and clears its pending id.
159
+ // On the INNER transport so the metrics tap still sees the frame and clears its pending id.
177
160
  void Promise.resolve(transport.send(outcome.response)).catch(fallback);
178
161
  }
179
162
  catch {
@@ -195,10 +178,8 @@ export function withArgNormalization(transport, shapeFor, log, onRename, strict)
195
178
  if (transport.setProtocolVersion) {
196
179
  wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
197
180
  }
198
- // v2-only, called by Protocol.connect() before start(). A no-op today (both
199
- // sides default to the same exported constant), but a proxy that silently
200
- // eats a member the SDK calls is a bug waiting for the first caller that
201
- // passes supportedProtocolVersions explicitly.
181
+ // A no-op today, but a proxy silently eating an SDK-called member is a bug
182
+ // waiting for the first caller that passes this explicitly.
202
183
  if (transport.setSupportedProtocolVersions) {
203
184
  wrapper.setSupportedProtocolVersions = (v) => transport.setSupportedProtocolVersions(v);
204
185
  }
@@ -230,9 +211,8 @@ export function outputFailureEnvelope(tool, text, account) {
230
211
  }
231
212
  export function rewriteValidationFrame(message, pending, opts) {
232
213
  const m = message;
233
- // Responses only. A server-initiated REQUEST (elicitation, sampling) also
234
- // carries an id, from a different id space, and evicting on it would drop a
235
- // real pending call.
214
+ // Responses only: a server-initiated REQUEST also carries an id (a
215
+ // different id space), and evicting on it would drop a real pending call.
236
216
  if (m.id === undefined || m.method !== undefined)
237
217
  return message;
238
218
  const call = pending.get(m.id);
@@ -1,14 +1,5 @@
1
- // Unknown-argument screening (backlog item 14). zod strips undeclared keys
2
- // before a handler runs, so a misremembered argument name used to produce a
3
- // SUCCESS response with wrong behavior: `parentId` on drive_create_folder was
4
- // dropped and the folder landed in My Drive root. Field-reported, then
5
- // reproduced: the typo, the correct name and a wholly invented key all
6
- // returned byte-identical responses.
7
- //
8
- // This module is pure and holds no registry reference. It never rewrites a key
9
- // and never touches a VALUE: it only decides that a key is undeclared, and
10
- // suggests what the caller probably meant. Rewriting would risk sending a real
11
- // value to Google on a guess, which is strictly worse than refusing.
1
+ // Unknown-argument screening: zod strips undeclared keys before a handler
2
+ // runs, so a misremembered name used to produce a SUCCESS with wrong behavior. See docs/internals.md.
12
3
  import { editDistance, SUGGEST_MAX_INPUT } from './scope-catalog.js';
13
4
  import { sliceClean } from './trim.js';
14
5
  /**
@@ -26,24 +17,15 @@ export function unknownArgMode(env = process.env) {
26
17
  process.stderr.write(`GOOGLE_ARG_UNKNOWN="${raw}" is not valid (reject | warn | off); using warn\n`);
27
18
  return 'warn';
28
19
  }
29
- // The staged rollout is over: `warn` shipped first as pure observability, and
30
- // this is the flip it was staging for. An undeclared argument is dropped by
31
- // zod before the handler runs, so `warn` means the CLIENT sees a confident
32
- // success for a call the server did not perform: drive_list with a misspelled
33
- // folder key returned the My Drive root, byte-identical to no argument at all.
34
- // A wrong answer that reads as right is the failure mode agents recover from
35
- // worst, so the default refuses the call and says what it probably meant.
20
+ // `warn` means the client sees a confident success for a call the server
21
+ // didn't perform; a wrong answer that reads as right is the worst failure mode to recover from.
36
22
  const DEFAULT_MODE = 'reject';
37
23
  /** Tools that legitimately accept open-ended top-level keys. Empty at 6.0.0:
38
24
  * the escape hatch is NOT one, because its open-endedness lives in the VALUES
39
25
  * of queryParams/body, never in its six fixed top-level keys. */
40
26
  export const STRICT_EXEMPT_TOOLS = new Set();
41
- /** Keys a CLIENT adds on its own, not keys the model chose. `random_string`
42
- * is the long-standing probe some clients send to a tool they read as taking
43
- * no arguments; the others are call-plumbing that bridges and proxies have
44
- * been observed to fold into `arguments` instead of `params._meta`, where the
45
- * spec puts them. Rejecting these would fail a call the model got right.
46
- * Measured against the full 978-tool surface: none is a declared key. */
27
+ // Keys a CLIENT adds on its own, not keys the model chose (a probe some
28
+ // clients send to argument-less tools, plus call-plumbing bridges fold into `arguments`).
47
29
  const CLIENT_ARTIFACT_KEYS = new Set([
48
30
  'random_string',
49
31
  'toolCallId',
@@ -138,10 +120,8 @@ export function screenArguments(tool, args, declared) {
138
120
  if (STRICT_EXEMPT_TOOLS.has(tool) || declared.length === 0)
139
121
  return out;
140
122
  const declaredSet = new Set(declared);
141
- // Tier 0 (same key modulo case and separators) is a map lookup, so every key
142
- // gets it and a redundant twin is recognised past the ranking budget too.
143
- // A near-miss duplicate (a typo sent next to its declared key) is recognised
144
- // only within the budget: a call needs 9 of them before one is refused.
123
+ // Tier 0 is a map lookup, so a redundant twin is recognised past the
124
+ // ranking budget too; a near-miss duplicate still needs that budget.
145
125
  const byFlat = new Map();
146
126
  for (const k of declared)
147
127
  byFlat.set(flatten(k), [...(byFlat.get(flatten(k)) ?? []), k]);
package/dist/auth.d.ts CHANGED
@@ -4,7 +4,7 @@ export declare const BASE_SCOPES: string[];
4
4
  export declare const OPTIONAL_SCOPE_BUNDLES: Record<string, string[]>;
5
5
  export { ADMIN_SCOPES };
6
6
  /** Union of every account's resolved bundles: a service registers if ANY
7
- * account can authorize it; per-account authz happens at call time (BR2). */
7
+ * account can authorize it; per-account authz happens at call time. */
8
8
  export declare function getOptionalBundles(set?: AccountSet): string[];
9
9
  /** Aliases granted ADMIN_SCOPES: per-account admin flag (env
10
10
  * GOOGLE_ADMIN_ACCOUNTS overrides config.json at resolve) OR the account's
package/dist/auth.js CHANGED
@@ -31,12 +31,8 @@ function parseCsvEnv(name) {
31
31
  .map(s => s.trim())
32
32
  .filter(Boolean);
33
33
  }
34
- /**
35
- * Legacy global override (BC7): a set GOOGLE_OPTIONAL_SCOPES acts as an
36
- * implicit "legacy-global" profile applied to every account (env wins over
37
- * file profiles, cc-config R2). Unknown names now fail loudly (BR3) where v5
38
- * silently dropped them — the intended migration signal.
39
- */
34
+ // A set GOOGLE_OPTIONAL_SCOPES acts as an implicit profile applied to every
35
+ // account (env wins over file profiles); unknown names fail loudly.
40
36
  function legacyGlobalProfile() {
41
37
  const names = parseCsvEnv('GOOGLE_OPTIONAL_SCOPES');
42
38
  if (names.length === 0)
@@ -65,7 +61,7 @@ function profileForAccount(alias, set = getAccountSet()) {
65
61
  return Object.hasOwn(set.scopeProfiles, name) ? set.scopeProfiles[name] : { bundles: [] };
66
62
  }
67
63
  /** Union of every account's resolved bundles: a service registers if ANY
68
- * account can authorize it; per-account authz happens at call time (BR2). */
64
+ * account can authorize it; per-account authz happens at call time. */
69
65
  export function getOptionalBundles(set = getAccountSet()) {
70
66
  const legacy = legacyGlobalProfile();
71
67
  if (legacy)
@@ -130,9 +126,8 @@ export async function runAuthFlow(args) {
130
126
  // Auto-provisions on a fresh install (env > keychain > file > generate);
131
127
  // resolves eagerly so a provisioning failure surfaces before the browser opens.
132
128
  resolveMasterKey();
133
- // Shared ephemeral-port loopback flow (oauth-consent.ts): listen first, then
134
- // build the auth URL from the assigned redirect. CLI gets a patient timeout;
135
- // errors propagate to main()'s fatal handler like every other CLI failure.
129
+ // Listen first, then build the auth URL from the assigned redirect; errors
130
+ // propagate to main()'s fatal handler like every other CLI failure.
136
131
  const loop = await openLoopbackConsent({ timeoutMs: 10 * 60_000 });
137
132
  const oauth2Client = buildConsentClient(loop.redirect);
138
133
  // CSRF protection for the OAuth callback (RFC 6749 §10.12).
@@ -11,7 +11,7 @@ export interface MtBootGates {
11
11
  export declare function setMtBootGates(g: MtBootGates | null): void;
12
12
  export declare function mtBootGates(): MtBootGates | null;
13
13
  export declare function isMultiTenantBoot(): boolean;
14
- /** BR-4 applies to stdio always, and to HTTP only when single-owner: a
14
+ /** The empty-registry guard applies to stdio always, and to HTTP only when single-owner: a
15
15
  * multi-tenant HTTP deploy starts empty by design. */
16
16
  export declare function accountsAssertRequired(wantStdio: boolean, multiTenant: boolean): boolean;
17
17
  /** A set GOOGLE_ACCOUNTS on a multi-tenant boot is refused at DEPLOY time:
@@ -1,7 +1,5 @@
1
- // S1.16: multi-tenant boot gates. The free core never sets these — every
2
- // helper reduces to today's single-owner behavior when no gates are
3
- // installed. Deliberately a side-effect-free module (accounts.ts/auth.ts
4
- // resolve env at import time) so the tenancy module can import it cheaply.
1
+ // Multi-tenant boot gates: every helper reduces to single-owner behavior when
2
+ // no gates are installed. Deliberately side-effect-free, unlike accounts.ts/auth.ts.
5
3
  import { failStartup } from './config-file.js';
6
4
  let gates = null;
7
5
  export function setMtBootGates(g) {
@@ -13,7 +11,7 @@ export function mtBootGates() {
13
11
  export function isMultiTenantBoot() {
14
12
  return gates?.multiTenant === true;
15
13
  }
16
- /** BR-4 applies to stdio always, and to HTTP only when single-owner: a
14
+ /** The empty-registry guard applies to stdio always, and to HTTP only when single-owner: a
17
15
  * multi-tenant HTTP deploy starts empty by design. */
18
16
  export function accountsAssertRequired(wantStdio, multiTenant) {
19
17
  return wantStdio || !multiTenant;
@@ -1,9 +1,5 @@
1
- // H4 F6: GET /authorize is unauthenticated, and a client_id on an allowlisted
2
- // issuer with a path never seen before forces an outbound fetch. So the
3
- // fetches running at once are capped, one client_id is fetched once however
4
- // many requests wait on it, and only a document that passed validation is
5
- // kept. Errors are never cached (CIMD section 5.2), and a negative cache would
6
- // not help anyway: the attack varies the path, so its key never repeats.
1
+ // Bounds the unauthenticated-fetch surface of GET /authorize: capped
2
+ // in-flight fetches, one fetch per client_id, no error caching. See docs/internals.md.
7
3
  import { CimdHttpError, isTransientFetchError, SsrfBlockedError } from './ssrf-guard.js';
8
4
  import { logSafe } from './trim.js';
9
5
  export const CIMD_TTL_MS = 5 * 60_000;
@@ -69,16 +65,14 @@ export class CimdClientCache {
69
65
  }
70
66
  catch (e) {
71
67
  out = { error: e instanceof SsrfBlockedError ? 'E_CIMD_SSRF_BLOCKED' : 'E_CIMD_INVALID' };
72
- // A document that went bad is never served stale. An issuer that is down
73
- // or overloaded says nothing about the document, so the last good one
74
- // stays and answers this fetch's requests too (stale-if-error; the error
75
- // itself is never stored).
68
+ // stale-if-error: an issuer that's down/overloaded says nothing about
69
+ // the document, so the last good one stays and serves this fetch too.
76
70
  const stale = this.entries.get(clientId);
77
71
  if (!saysNothingAboutDocument(e))
78
72
  this.entries.delete(clientId);
79
73
  else if (stale && this.deps.now() - stale.at < CIMD_MAX_STALE_MS)
80
74
  out = { doc: stale.doc, fresh: false };
81
- // #13: the detail goes to the log only; the caller gets a generic answer.
75
+ // The detail goes to the log only; the caller gets a generic answer.
82
76
  this.deps.log(`CIMD fetch failed for ${logSafe(clientId, 128)}: ${logSafe(e instanceof Error ? e.message : 'non-Error rejection', 200)}`);
83
77
  }
84
78
  finally {
@@ -38,7 +38,7 @@ export interface EntryOptions {
38
38
  }
39
39
  /**
40
40
  * The client `mcpServers` entry. stdio needs no secret env: the server loads
41
- * its own .env from ~/.config/mcp-google-multi (cc-config R1), so we never
41
+ * its own .env from ~/.config/mcp-google-multi, so we never
42
42
  * inline MASTER_KEY / GOOGLE_CLIENT_SECRET into a world-readable client config.
43
43
  */
44
44
  export declare function buildServerEntry(opts: EntryOptions): ServerEntry;
@@ -73,7 +73,7 @@ export type ApplyResult = {
73
73
  * the prior file, update-in-place if the key exists.
74
74
  */
75
75
  export declare function applyFileEntry(client: ClientInfo, name: string, entry: ServerEntry, deps?: ClientDeps): ApplyResult;
76
- /** Resolve stdio-vs-http mode from the current transport config (B11).
76
+ /** Resolve stdio-vs-http mode from the current transport config.
77
77
  * http/both → a `url` entry; stdio → a `command` entry. */
78
78
  export declare function resolveMode(httpConfig?: {
79
79
  transport: 'stdio' | 'http' | 'both';
@@ -1,9 +1,5 @@
1
- // B15: after the setup wizard finalizes an account, offer to register the
2
- // server with the user's MCP client so they never hand-edit JSON. Pure core
3
- // (detection + entry building + idempotent apply), deps-injectable for tests.
4
- // Consent + non-TTY behavior live in the CLI/tool surfaces; secrets are NEVER
5
- // inlined — the server self-loads its .env from ~/.config/mcp-google-multi, so
6
- // a stdio entry needs no secret env at all (distribution.md B15).
1
+ // Registers the server with the user's MCP client so they never hand-edit
2
+ // JSON. Pure core (detection + entry building + idempotent apply), deps-injectable for tests.
7
3
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
8
4
  import * as readline from 'node:readline';
9
5
  import path from 'node:path';
@@ -20,9 +16,8 @@ function resolve(deps) {
20
16
  mkdirp: deps.mkdirp ?? ((dir) => mkdirSync(dir, { recursive: true })),
21
17
  };
22
18
  }
23
- // Build paths for the TARGET platform's separators, not the running host's, so
24
- // the injected `platform` is honored deterministically (a Windows CI runner
25
- // must still produce posix paths for a linux target, and vice versa).
19
+ // Build paths for the TARGET platform's separators, not the running host's,
20
+ // so the injected `platform` is honored deterministically.
26
21
  function pp(r) {
27
22
  return r.platform === 'win32' ? path.win32 : path.posix;
28
23
  }
@@ -78,7 +73,7 @@ export function detectClients(deps = {}) {
78
73
  }
79
74
  /**
80
75
  * The client `mcpServers` entry. stdio needs no secret env: the server loads
81
- * its own .env from ~/.config/mcp-google-multi (cc-config R1), so we never
76
+ * its own .env from ~/.config/mcp-google-multi, so we never
82
77
  * inline MASTER_KEY / GOOGLE_CLIENT_SECRET into a world-readable client config.
83
78
  */
84
79
  export function buildServerEntry(opts) {
@@ -155,9 +150,8 @@ export function applyFileEntry(client, name, entry, deps = {}) {
155
150
  const had = Object.prototype.hasOwnProperty.call(prevServers, name);
156
151
  const next = { ...existing, mcpServers: { ...prevServers, [name]: entry } };
157
152
  r.mkdirp(pp(r).dirname(p));
158
- // Back up the prior file before rewriting so a bad write is recoverable
159
- // (these foreign configs are small and not concurrently written by us, so a
160
- // backup + direct write is sufficient — our own config.json uses fs-atomic).
153
+ // Back up before rewriting so a bad write is recoverable; our own
154
+ // config.json uses fs-atomic, but these small foreign configs don't need it.
161
155
  let backup;
162
156
  if (priorRaw !== null) {
163
157
  backup = `${p}.bak`;
@@ -166,7 +160,7 @@ export function applyFileEntry(client, name, entry, deps = {}) {
166
160
  r.writeFile(p, `${JSON.stringify(next, null, 2)}\n`);
167
161
  return { ok: true, client: client.id, action: had ? 'updated' : 'added', path: p, backup };
168
162
  }
169
- /** Resolve stdio-vs-http mode from the current transport config (B11).
163
+ /** Resolve stdio-vs-http mode from the current transport config.
170
164
  * http/both → a `url` entry; stdio → a `command` entry. */
171
165
  export function resolveMode(httpConfig) {
172
166
  if (httpConfig && (httpConfig.transport === 'http' || httpConfig.transport === 'both')) {
package/dist/client.js CHANGED
Binary file
package/dist/compose.d.ts CHANGED
@@ -1,8 +1,15 @@
1
- import type { McpServer } from "@modelcontextprotocol/server";
1
+ import { McpServer } from "@modelcontextprotocol/server";
2
2
  import type { ServerTarget } from './http-transport.js';
3
3
  import { ToolRegistry, type DiscoveryMode } from './registry.js';
4
4
  import { type IdentityContext } from './identity.js';
5
5
  import type { Metrics } from './usage-metrics.js';
6
+ export interface McpServerOptions {
7
+ /** Sent in the initialize result; omitted entirely when absent or blank. */
8
+ instructions?: string;
9
+ }
10
+ /** The one place the MCP server is constructed, so an embedding host can pass
11
+ * per-session `instructions` without re-deriving the server identity. */
12
+ export declare function createMcpServer(version: string, opts?: McpServerOptions): McpServer;
6
13
  export declare function buildRegistry(server: McpServer, ctx: IdentityContext, mode?: DiscoveryMode, metrics?: Metrics | null): ToolRegistry;
7
14
  /** The per-request hooks bound to one registry: argument shapes, unknown-
8
15
  * argument screening and the account a validation envelope names. Both
package/dist/compose.js CHANGED
@@ -1,8 +1,6 @@
1
- // S1.12: registry composition, extracted from index.ts so the tenancy module
2
- // (and tests) can build registries without touching the CLI entrypoint. This
3
- // module must stay free of import-time side effects of its own: no argv
4
- // parsing, no process.exit, no prints (accounts.js module-load resolution is
5
- // the one inherited, pre-existing exception on the import chain).
1
+ // Registry composition, extracted from index.ts so tests can build registries
2
+ // without touching the CLI entrypoint. No import-time side effects of its own.
3
+ import { McpServer } from "@modelcontextprotocol/server";
6
4
  import { GENERATED_SERVICES } from './tools/generated/index.js';
7
5
  import { GENERATED_GATES, SERVICES, servicesAwaitingRestart, unknownToolMessage } from './services.js';
8
6
  import { argNormalizationEnabled } from './arg-normalize.js';
@@ -16,15 +14,17 @@ import { registerAccountWizardTools } from './tools/account-wizard.js';
16
14
  import { getToolsets, toolsetEnabled } from './toolsets.js';
17
15
  import { isOwnerContext } from './identity.js';
18
16
  import { resolveScopesForAccount } from './auth.js';
17
+ /** The one place the MCP server is constructed, so an embedding host can pass
18
+ * per-session `instructions` without re-deriving the server identity. */
19
+ export function createMcpServer(version, opts = {}) {
20
+ const instructions = opts.instructions?.trim() ? opts.instructions : undefined;
21
+ return new McpServer({ name: 'mcp-google-multi', version }, instructions ? { instructions } : undefined);
22
+ }
19
23
  export function buildRegistry(server, ctx, mode, metrics = null) {
20
24
  const policy = ctx.policy;
21
- // The registry's account view IS the context's: fan-out expansion,
22
- // selector validation and default-account injection all follow this
23
- // context, not the process global (identical for the free core's one ctx).
25
+ // The registry's account view IS the context's, not the process global.
24
26
  const registry = new ToolRegistry(server, policy, mode, metrics, () => ctx.accounts);
25
- // Every handler resolves its client, token reads and scope hints through
26
- // this context; ctx.accounts is read per call, never snapshotted here, so
27
- // the owner's live getter stays live.
27
+ // ctx.accounts is read per call, never snapshotted here, so a live getter stays live.
28
28
  const owner = isOwnerContext(ctx);
29
29
  const clientDeps = { getClientFn: ctx.getClient, localFiles: owner };
30
30
  const execDeps = {