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/README.md CHANGED
@@ -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,14 @@ 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
+ ## Maintainer, sponsor & license
59
59
 
60
60
  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
61
 
62
62
  Development is **funded by [IdeaCrafters](https://ideacrafters.com)** ([@IdeaCraftersHQ](https://github.com/IdeaCraftersHQ)) — the studio that pays for this OSS to exist.
63
63
 
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.
64
+ An Enterprise Edition (hosted, multi-tenant) is available: ask through [berkati.xyz](https://berkati.xyz) (email or LinkedIn there).
65
65
 
66
- ## License
66
+ 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
67
 
68
- [MIT](./LICENSE)
68
+ Licensed under [MIT](./LICENSE).
@@ -18,13 +18,17 @@ export interface AccountSet {
18
18
  defaultAccount?: string;
19
19
  defaultAccountSource?: 'env' | 'config';
20
20
  }
21
- export declare function resolveAccounts(env?: NodeJS.ProcessEnv, filePath?: string, onInvalid?: 'exit' | 'throw'): AccountSet;
21
+ export declare function resolveAccounts(env?: NodeJS.ProcessEnv, filePath?: string, onInvalid?: 'exit' | 'throw', opts?: {
22
+ tokenDir?: string;
23
+ emptyOk?: boolean;
24
+ }): AccountSet;
25
+ export declare function fileStamp(filePath: string, version: number): string;
22
26
  /** Live accessor: dispatch-time readers use this, never a captured snapshot. */
23
27
  export declare function getAccountSet(): AccountSet;
24
28
  /** Re-resolve after a config.json mutation and swap the live set. */
25
29
  export declare function invalidateAccountSet(): AccountSet;
26
30
  export declare function refreshAccountSetIfStale(): void;
27
- /** Cross-process staleness probe (BR-7): one stat, compared against the stamp. */
31
+ /** Cross-process staleness probe: one stat, compared against the stamp. */
28
32
  export declare function isAccountSetStale(): boolean;
29
33
  /** Account aliases (possibly empty on a fresh install).
30
34
  * Snapshot from the initial load; enums widen only when the registry is
@@ -39,14 +43,26 @@ export declare function unknownAliasMessage(aliases: readonly string[], selector
39
43
  * The `account` param schema, empty-registry-safe. A `z.enum` requires at least
40
44
  * one value, so a fresh install (zero aliases) would throw at schema-build time
41
45
  * (module load) and take down every CLI — including `doctor`, which is meant to
42
- * 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
43
47
  * are no aliases: no alias exists to enumerate, the server refuses to boot empty
44
48
  * anyway, and dispatch validates the account against the live set. */
45
49
  export declare function accountAliasSchemaFor(aliases: readonly string[]): z.ZodType<string>;
46
50
  /** Shared, load-time snapshot used by every tool's `account` field. */
47
51
  export declare const accountAliasSchema: z.ZodType<string>;
52
+ /** Live account argument: validates at PARSE time against the accessor's
53
+ * CURRENT aliases, so an alias added mid-session (account_add, or a tenant
54
+ * alias link) is immediately valid on already-registered tools — a baked
55
+ * z.enum freezes the boot-time set until restart. An empty set stays
56
+ * permissive (same empty-safe rule as above); dispatch still resolves the
57
+ * alias against the live registry. */
58
+ export declare function accountArgLive(aliases: () => readonly string[]): z.ZodType<string>;
59
+ /** The live membership check behind accountArgLive and the fan-out field;
60
+ * `selectors` also admits "*". An empty set stays permissive. */
61
+ export declare function liveAccountCheck(aliases: () => readonly string[], selectors: boolean): z.core.$ZodCheck<string>;
62
+ /** True for a field built on liveAccountCheck, optional or not. */
63
+ export declare function isLiveAccountField(field: unknown): boolean;
48
64
  /**
49
- * 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
50
66
  * bootstraps via env / `migrate-config` / `account import` / `auth` first. The
51
67
  * bootstrap and diagnostic CLIs return before this guard, so it gates only the
52
68
  * server path (called from index.ts after the CLI branches). */
package/dist/accounts.js CHANGED
@@ -2,8 +2,8 @@ import path from 'node:path';
2
2
  import fs from 'node:fs';
3
3
  import { z } from 'zod';
4
4
  import { loadEnvFiles } from './env-load.js';
5
- import { CONFIG_VERSION, configDir, configFilePath, failStartup, loadConfigFile, mutateConfigFile } from './config-file.js';
6
- import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
5
+ import { ALIAS_RE, CONFIG_VERSION, configDir, configFilePath, failStartup, isReservedAlias, loadConfigFile, mutateConfigFile } from './config-file.js';
6
+ import { closestBundle, isKnownBundle, resolveBundleAliases } from './scope-catalog.js';
7
7
  const envLoad = loadEnvFiles();
8
8
  const defaultTokenDir = path.join(configDir(), 'tokens');
9
9
  const tokenDir = process.env.TOKEN_STORE_PATH
@@ -15,14 +15,14 @@ export function getTokenDir() {
15
15
  function parseCsv(value) {
16
16
  return (value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
17
17
  }
18
- function accountPaths(alias) {
18
+ function accountPaths(alias, dir = tokenDir) {
19
19
  return {
20
- tokenPath: path.join(tokenDir, alias, 'token.json'),
21
- encPath: path.join(tokenDir, `${alias}.enc`),
20
+ tokenPath: path.join(dir, alias, 'token.json'),
21
+ encPath: path.join(dir, `${alias}.enc`),
22
22
  };
23
23
  }
24
24
  /** v5 env parser, guards verbatim. Format: GOOGLE_ACCOUNTS="alias1:email1,alias2:email2". */
25
- function parseEnvAccounts(raw, adminAliases) {
25
+ function parseEnvAccounts(raw, adminAliases, dir = tokenDir) {
26
26
  const configs = {};
27
27
  const aliases = [];
28
28
  for (const entry of raw.split(',')) {
@@ -40,7 +40,7 @@ function parseEnvAccounts(raw, adminAliases) {
40
40
  }
41
41
  // Restrict alias to a safe charset so it can't escape `tokenDir` via path traversal
42
42
  // (e.g. "../../etc/passwd:foo@bar.com" in .env).
43
- if (!/^[a-zA-Z0-9_-]+$/.test(alias) || ['__proto__', 'constructor', 'prototype'].includes(alias)) {
43
+ if (!ALIAS_RE.test(alias) || isReservedAlias(alias)) {
44
44
  throw new Error(`Invalid alias "${alias}". Allowed characters: letters, digits, underscore, hyphen.`);
45
45
  }
46
46
  if (aliases.includes(alias)) {
@@ -49,7 +49,7 @@ function parseEnvAccounts(raw, adminAliases) {
49
49
  aliases.push(alias);
50
50
  configs[alias] = {
51
51
  email,
52
- ...accountPaths(alias),
52
+ ...accountPaths(alias, dir),
53
53
  admin: adminAliases.includes(alias) || undefined,
54
54
  source: 'env',
55
55
  };
@@ -67,14 +67,17 @@ 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);
74
74
  * empty behaves as unset, mirroring GOOGLE_ACCOUNTS semantics.
75
75
  */
76
76
  let warnedLegacy = false;
77
- export function resolveAccounts(env = process.env, filePath = configFilePath(), onInvalid = 'exit') {
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
+ opts = {}) {
80
+ const dir = opts.tokenDir ?? tokenDir;
78
81
  const adminEnv = parseCsv(env.GOOGLE_ADMIN_ACCOUNTS);
79
82
  const rawEnv = env.GOOGLE_ACCOUNTS;
80
83
  const fail = (slug, message) => {
@@ -82,16 +85,15 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
82
85
  throw new Error(`${slug}: ${message}`);
83
86
  failStartup(slug, message);
84
87
  };
85
- // Validate the legacy global override AT BOOT, whatever GOOGLE_TOOLSETS
86
- // selects — otherwise a typo only surfaces at dispatch time inside
87
- // 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.
88
90
  const legacyNames = parseCsv(env.GOOGLE_OPTIONAL_SCOPES);
89
91
  if (legacyNames.length > 0) {
90
92
  for (const bundle of resolveBundleAliases(legacyNames)) {
91
93
  if (bundle === 'admin') {
92
94
  fail('E_UNKNOWN_BUNDLE', '"admin" is not a global bundle: grant it per account via GOOGLE_ADMIN_ACCOUNTS or an "admin: true" scope profile.');
93
95
  }
94
- if (!(bundle in BUNDLE_CATALOG)) {
96
+ if (!isKnownBundle(bundle)) {
95
97
  const hint = closestBundle(bundle);
96
98
  fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
97
99
  }
@@ -102,7 +104,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
102
104
  }
103
105
  }
104
106
  if (rawEnv && rawEnv.trim() !== '') {
105
- const { aliases, configs } = parseEnvAccounts(rawEnv, adminEnv);
107
+ const { aliases, configs } = parseEnvAccounts(rawEnv, adminEnv, dir);
106
108
  materializeFirstRun(aliases, configs, filePath);
107
109
  const def = resolveDefaultAccount(env, null, aliases, fail);
108
110
  return {
@@ -116,20 +118,14 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
116
118
  ...def,
117
119
  };
118
120
  }
119
- // Stat BEFORE read: a cross-process write landing between the two makes the
120
- // stamp conservative (flags stale again next dispatch) instead of pinning
121
- // stale content behind a fresh stamp.
121
+ // Stat BEFORE read, so a race lands on the conservative (stale) side.
122
122
  const preStamp = fileStamp(filePath, CONFIG_VERSION);
123
123
  const config = loadConfigFile(filePath, onInvalid);
124
124
  const entries = Object.entries(config?.accounts ?? {});
125
125
  if (entries.length === 0) {
126
- // Gap #23: an empty registry is NOT fatal at resolve time — the bootstrap and
127
- // diagnostic CLIs (doctor/reset/account import/migrate-config/config check)
128
- // must run on a fresh install. Only the stdio/http SERVER refuses to boot
129
- // empty (BR-4), enforced by assertServerAccountsConfigured() in index.ts.
130
- // The dispatch-path reload ('throw') still throws so a mid-session emptied
131
- // config.json keeps the last-good registry (BR-7) instead of dropping tools.
132
- if (onInvalid === 'throw') {
126
+ // Not fatal here: bootstrap/diagnostic CLIs must run on a fresh install;
127
+ // only the server refuses to boot empty (index.ts).
128
+ if (onInvalid === 'throw' && !opts.emptyOk) {
133
129
  throw new Error(`E_NO_ACCOUNTS_CONFIGURED: ${noAccountsMessage()}`);
134
130
  }
135
131
  return {
@@ -140,15 +136,14 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
140
136
  stamp: `${config?.version ?? CONFIG_VERSION}:${preStamp.split(':')[1]}`,
141
137
  };
142
138
  }
143
- // BR3: unknown bundle names fail loudly (a mis-scoped token is worse than a
144
- // clear error); v5 silently filtered them. Null prototype: profile names are
145
- // 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.
146
141
  const scopeProfiles = Object.create(null);
147
142
  scopeProfiles.base = { bundles: [] };
148
143
  for (const [name, profile] of Object.entries(config?.scopeProfiles ?? {})) {
149
144
  const bundles = resolveBundleAliases(profile.bundles);
150
145
  for (const bundle of bundles) {
151
- if (!(bundle in BUNDLE_CATALOG)) {
146
+ if (!isKnownBundle(bundle)) {
152
147
  const hint = closestBundle(bundle);
153
148
  fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in scope profile "${name}"${hint ? ` — did you mean "${hint}"?` : ''}`);
154
149
  }
@@ -167,7 +162,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
167
162
  aliases.push(alias);
168
163
  configs[alias] = {
169
164
  email: entry.email,
170
- ...accountPaths(alias),
165
+ ...accountPaths(alias, dir),
171
166
  scopeProfile: entry.scopeProfile,
172
167
  admin: adminEnv.length > 0 ? adminEnv.includes(alias) : entry.admin,
173
168
  source: 'config',
@@ -183,9 +178,8 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
183
178
  ...def,
184
179
  };
185
180
  }
186
- /** A2: env GOOGLE_DEFAULT_ACCOUNT > config.defaultAccount > unset. A configured
187
- * default naming an unknown alias refuses to start (E_DEFAULT_ACCOUNT_UNKNOWN —
188
- * 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).
189
183
  function resolveDefaultAccount(env, fromConfig, aliases, fail) {
190
184
  const fromEnv = env.GOOGLE_DEFAULT_ACCOUNT?.trim();
191
185
  const value = fromEnv || fromConfig || undefined;
@@ -196,7 +190,7 @@ function resolveDefaultAccount(env, fromConfig, aliases, fail) {
196
190
  }
197
191
  return { defaultAccount: value, defaultAccountSource: fromEnv ? 'env' : 'config' };
198
192
  }
199
- function fileStamp(filePath, version) {
193
+ export function fileStamp(filePath, version) {
200
194
  try {
201
195
  return `${version}:${fs.statSync(filePath).mtimeMs}`;
202
196
  }
@@ -204,17 +198,14 @@ function fileStamp(filePath, version) {
204
198
  return `${version}:0`;
205
199
  }
206
200
  }
207
- // First-run shim (BC6): env is set and no config.json exists yet — materialize
208
- // the file so the wizard has something to edit. Env still wins this session;
209
- // 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.
210
203
  function materializeFirstRun(aliases, configs, filePath) {
211
204
  if (fs.existsSync(filePath))
212
205
  return;
213
206
  try {
214
207
  let wrote = false;
215
- // mutateConfigFile = lock + re-check + atomic write, so a concurrent
216
- // wizard/migrate writer is never clobbered (the loaded `current` is
217
- // 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.
218
209
  mutateConfigFile((current) => {
219
210
  if (Object.keys(current.accounts ?? {}).length > 0)
220
211
  return current;
@@ -247,7 +238,7 @@ export function invalidateAccountSet() {
247
238
  return current;
248
239
  }
249
240
  /**
250
- * 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,
251
242
  * corrupt, or deleted config.json keeps the last-good set and warns once per
252
243
  * distinct failure on stderr. failStartup semantics are boot/CLI-only.
253
244
  */
@@ -267,7 +258,7 @@ export function refreshAccountSetIfStale() {
267
258
  }
268
259
  }
269
260
  }
270
- /** Cross-process staleness probe (BR-7): one stat, compared against the stamp. */
261
+ /** Cross-process staleness probe: one stat, compared against the stamp. */
271
262
  export function isAccountSetStale() {
272
263
  if (current.source !== 'file')
273
264
  return false;
@@ -283,6 +274,8 @@ export const ACCOUNTS = current.aliases;
283
274
  * enum actually accepts, and naming a runtime-added alias as valid while
284
275
  * rejecting it would be a lie. */
285
276
  export function unknownAliasMessage(aliases, selectors = false) {
277
+ if (aliases.length === 0)
278
+ return 'No Google account is configured yet. Add one with account_add.';
286
279
  const all = selectors ? '; "*" for all accounts' : '';
287
280
  const csv = selectors && aliases.length > 1 ? `; or a CSV subset like "${aliases.slice(0, 2).join(',')}"` : '';
288
281
  return `Unknown account alias. Valid: ${aliases.join(', ')}${all}${csv}. Run account_list if accounts changed since this server started.`;
@@ -291,7 +284,7 @@ export function unknownAliasMessage(aliases, selectors = false) {
291
284
  * The `account` param schema, empty-registry-safe. A `z.enum` requires at least
292
285
  * one value, so a fresh install (zero aliases) would throw at schema-build time
293
286
  * (module load) and take down every CLI — including `doctor`, which is meant to
294
- * 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
295
288
  * are no aliases: no alias exists to enumerate, the server refuses to boot empty
296
289
  * anyway, and dispatch validates the account against the live set. */
297
290
  export function accountAliasSchemaFor(aliases) {
@@ -301,8 +294,39 @@ export function accountAliasSchemaFor(aliases) {
301
294
  }
302
295
  /** Shared, load-time snapshot used by every tool's `account` field. */
303
296
  export const accountAliasSchema = accountAliasSchemaFor(ACCOUNTS);
297
+ /** Live account argument: validates at PARSE time against the accessor's
298
+ * CURRENT aliases, so an alias added mid-session (account_add, or a tenant
299
+ * alias link) is immediately valid on already-registered tools — a baked
300
+ * z.enum freezes the boot-time set until restart. An empty set stays
301
+ * permissive (same empty-safe rule as above); dispatch still resolves the
302
+ * alias against the live registry. */
303
+ export function accountArgLive(aliases) {
304
+ return z.string().check(liveAccountCheck(aliases, false));
305
+ }
306
+ // The check instance survives the clones .describe() and .optional() make,
307
+ // so it is what marks a field as an account selector for the registry.
308
+ const LIVE_ACCOUNT_CHECKS = new WeakSet();
309
+ /** The live membership check behind accountArgLive and the fan-out field;
310
+ * `selectors` also admits "*". An empty set stays permissive. */
311
+ export function liveAccountCheck(aliases, selectors) {
312
+ const check = z.superRefine((value, ctx) => {
313
+ const current = aliases();
314
+ if (current.length === 0 || current.includes(value) || (selectors && value === '*'))
315
+ return;
316
+ ctx.addIssue({ code: 'custom', message: unknownAliasMessage(current, selectors) });
317
+ });
318
+ LIVE_ACCOUNT_CHECKS.add(check);
319
+ return check;
320
+ }
321
+ /** True for a field built on liveAccountCheck, optional or not. */
322
+ export function isLiveAccountField(field) {
323
+ let def = field?._zod?.def;
324
+ if (def?.type === 'optional')
325
+ def = def.innerType?._zod?.def;
326
+ return def?.checks?.some((c) => LIVE_ACCOUNT_CHECKS.has(c)) ?? false;
327
+ }
304
328
  /**
305
- * 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
306
330
  * bootstraps via env / `migrate-config` / `account import` / `auth` first. The
307
331
  * bootstrap and diagnostic CLIs return before this guard, so it gates only the
308
332
  * server path (called from index.ts after the CLI branches). */
@@ -1,3 +1,5 @@
1
+ import { getClient } from './client.js';
2
+ import { readToken } from './token-store.js';
1
3
  import type { ApiProbeResult } from './doctor.js';
2
4
  export interface ApiProbeSpec {
3
5
  service: string;
@@ -15,4 +17,7 @@ export interface ApiProbeDeps {
15
17
  grantedScopes: (alias: string) => string[];
16
18
  request: (alias: string, url: string) => Promise<void>;
17
19
  }
20
+ /** Probe deps over one context's token reads and client; the module-level
21
+ * pair is the single owner's. */
22
+ export declare function apiProbeDepsFor(readTokenFn: typeof readToken, getClientFn: typeof getClient): ApiProbeDeps;
18
23
  export declare function probeApiEnablement(alias: string, deps?: ApiProbeDeps): Promise<ApiProbeResult[]>;
package/dist/api-probe.js CHANGED
@@ -24,21 +24,26 @@ export const API_PROBES = [
24
24
  export function planProbes(granted, probes = API_PROBES) {
25
25
  return probes.filter((p) => granted.some((s) => p.scopePrefixes.some((prefix) => s.startsWith(prefix))));
26
26
  }
27
- const DEFAULT_DEPS = {
28
- grantedScopes: (alias) => {
29
- try {
30
- const scope = readToken(alias)?.scope;
31
- return typeof scope === 'string' ? scope.split(' ').filter(Boolean) : [];
32
- }
33
- catch {
34
- return [];
35
- }
36
- },
37
- request: async (alias, url) => {
38
- const auth = await getClient(alias);
39
- await auth.request({ url, timeout: 10_000 });
40
- },
41
- };
27
+ /** Probe deps over one context's token reads and client; the module-level
28
+ * pair is the single owner's. */
29
+ export function apiProbeDepsFor(readTokenFn, getClientFn) {
30
+ return {
31
+ grantedScopes: (alias) => {
32
+ try {
33
+ const scope = readTokenFn(alias)?.scope;
34
+ return typeof scope === 'string' ? scope.split(' ').filter(Boolean) : [];
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ },
40
+ request: async (alias, url) => {
41
+ const auth = await getClientFn(alias);
42
+ await auth.request({ url, timeout: 10_000 });
43
+ },
44
+ };
45
+ }
46
+ const DEFAULT_DEPS = apiProbeDepsFor(readToken, getClient);
42
47
  export async function probeApiEnablement(alias, deps = DEFAULT_DEPS) {
43
48
  const results = [];
44
49
  for (const spec of planProbes(deps.grantedScopes(alias))) {
@@ -1,10 +1,7 @@
1
- import { screenArguments, unknownArgEnvelope, } from './arg-strict.js';
2
- import { 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 "{".
1
+ import { echoName, echoNames, screenArguments, unknownArgEnvelope, } from './arg-strict.js';
2
+ import { logSafe, sliceClean } from './trim.js';
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,8 @@ 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.
119
- log(`[args] ${tool}: undeclared ${all.join(', ')}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
105
+ // Key names only, escaped here and only here: the caller's names, never values, reach the log.
106
+ log(`[args] ${tool}: undeclared ${logSafe(echoNames(all), 600)}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
120
107
  try {
121
108
  // Only ever a DECLARED key or the literal placeholder, so the metrics
122
109
  // closed-vocabulary rule holds: the caller's key is never persisted.
@@ -132,7 +119,7 @@ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}
132
119
  const siblings = screened.unknown.some((u) => u.suggestions.length > 0)
133
120
  ? []
134
121
  : (opts.siblingsFor?.(tool, screened.unknown.map((u) => u.sent)) ?? []);
135
- const envelope = unknownArgEnvelope(tool, screened.unknown, declared, typeof account === 'string' ? account : undefined, siblings);
122
+ const envelope = unknownArgEnvelope(tool, screened.unknown, declared, typeof account === 'string' ? echoName(account) : undefined, siblings);
136
123
  return {
137
124
  action: 'reject',
138
125
  response: {
@@ -166,13 +153,10 @@ export function withArgNormalization(transport, shapeFor, log, onRename, strict)
166
153
  if (outcome.action === 'forward')
167
154
  return handler(outcome.msg, extra);
168
155
  // Last-resort fallback: dispatch as before rather than hang the
169
- // caller. The stderr line and the counter already fired above.
170
- // `send` is async, so a rejected promise needs catching too: a
171
- // 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.
172
157
  const fallback = () => handler(normalized, extra);
173
158
  try {
174
- // Answer on the INNER transport so the metrics tap still sees the
175
- // frame and clears its pending id.
159
+ // On the INNER transport so the metrics tap still sees the frame and clears its pending id.
176
160
  void Promise.resolve(transport.send(outcome.response)).catch(fallback);
177
161
  }
178
162
  catch {
@@ -194,10 +178,8 @@ export function withArgNormalization(transport, shapeFor, log, onRename, strict)
194
178
  if (transport.setProtocolVersion) {
195
179
  wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
196
180
  }
197
- // v2-only, called by Protocol.connect() before start(). A no-op today (both
198
- // sides default to the same exported constant), but a proxy that silently
199
- // eats a member the SDK calls is a bug waiting for the first caller that
200
- // 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.
201
183
  if (transport.setSupportedProtocolVersions) {
202
184
  wrapper.setSupportedProtocolVersions = (v) => transport.setSupportedProtocolVersions(v);
203
185
  }
@@ -229,9 +211,8 @@ export function outputFailureEnvelope(tool, text, account) {
229
211
  }
230
212
  export function rewriteValidationFrame(message, pending, opts) {
231
213
  const m = message;
232
- // Responses only. A server-initiated REQUEST (elicitation, sampling) also
233
- // carries an id, from a different id space, and evicting on it would drop a
234
- // 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.
235
216
  if (m.id === undefined || m.method !== undefined)
236
217
  return message;
237
218
  const call = pending.get(m.id);
@@ -14,6 +14,13 @@ export declare const STRICT_EXEMPT_TOOLS: ReadonlySet<string>;
14
14
  * tools: no declared key starts with `_` or contains `/`, so neither rule can
15
15
  * shadow a real parameter. */
16
16
  export declare function isExemptKey(key: string): boolean;
17
+ /** A call can carry thousands of unknown keys, or one of megabytes: only the
18
+ * first few earn a did-you-mean, and the log line and the envelope echo a
19
+ * bounded prefix of each name. The reject decision itself is never capped. */
20
+ export declare const MAX_SUGGESTED_KEYS = 8;
21
+ export declare function echoName(name: string): string;
22
+ /** The first MAX_SUGGESTED_KEYS names, each capped, then "and N more". */
23
+ export declare function echoNames(names: readonly string[], quote?: boolean): string;
17
24
  /**
18
25
  * Rank declared keys for an unknown key. Tiers, best non-empty tier wins:
19
26
  * 0 same key modulo case and separators