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
@@ -1,28 +1,25 @@
1
1
  import { z } from 'zod';
2
- import { getAccountSet, invalidateAccountSet } from '../accounts.js';
3
- import { mutateConfigFile } from '../config-file.js';
2
+ import { getAccountSet, invalidateAccountSet, refreshAccountSetIfStale } from '../accounts.js';
3
+ import { ALIAS_RE, mutateConfigFile } from '../config-file.js';
4
4
  import { writeToken } from '../token-store.js';
5
5
  import { resolveScopesForAccount } from '../auth.js';
6
- import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from '../scope-catalog.js';
6
+ import { BUNDLE_CATALOG, closestBundle, isKnownBundle, refusedBundlesFor, resolveBundleAliases } from '../scope-catalog.js';
7
7
  import { openUrl } from '../open-url.js';
8
8
  import { coerceBoolean } from './_coerce.js';
9
9
  import { safeMessage, stringifyEnvelope } from './_errors.js';
10
10
  import { buildConsentClient, openLoopbackConsent, hasClientCredentials, TESTING_MODE_WARNING, } from '../oauth-consent.js';
11
11
  import { detectClients, buildServerEntry, renderInstruction, applyFileEntry, resolveMode, DEFAULT_SERVER_NAME, } from '../client-config.js';
12
- // B7: the elicitation-driven account_add / account_reauth wizard. It rebuilds
13
- // interactive account management on the mutable config.json registry so a
14
- // running server can add an account without the deployer editing env + running
15
- // a CLI. Google consent reuses the loopback flow (leg C); the HTTP-transport
16
- // consent path (${BASE}/authorize) is owned by the OAuth AS and lands with that
17
- // cluster. account_add over http is gated behind it.
18
- const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
19
- // The common optional bundles get their own checkbox (MCP elicitation has no
20
- // multi-select, so a boolean per bundle IS the checklist); the long tail stays
21
- // a comma-separated field for power users, and `allBundles` grants every one.
12
+ // Each common bundle gets its own checkbox since MCP elicitation has no
13
+ // multi-select; the long tail stays a comma-separated field, `allBundles` grants all.
22
14
  const CHECKBOX_BUNDLES = ['forms', 'chat'];
23
- /** Every optional bundle (admin is granted via its own checkbox, not here). */
24
- export function allOptionalBundles() {
25
- return Object.keys(BUNDLE_CATALOG).filter((n) => n !== 'admin');
15
+ /** Every optional bundle (admin is granted via its own checkbox, not here),
16
+ * less those Google refuses for the account at `email` when one is given. */
17
+ export function allOptionalBundles(email) {
18
+ const all = Object.keys(BUNDLE_CATALOG).filter((n) => n !== 'admin');
19
+ if (email === undefined)
20
+ return all;
21
+ const refused = new Set(refusedBundlesFor(email, all));
22
+ return all.filter((n) => !refused.has(n));
26
23
  }
27
24
  /** elicitation/create form schema (flat primitives only, per the MCP spec). */
28
25
  export function addFormSchema() {
@@ -55,8 +52,7 @@ export function validateAddForm(input, existingAliases) {
55
52
  return { ok: false, slug: 'E_VALIDATION', message: 'Email is required.', hint: "It is used as the Google login hint, so it must be the account's real Google address.", alias };
56
53
  }
57
54
  if (input.allBundles === true) {
58
- // "All optional scopes" supersedes the individual picks.
59
- return { ok: true, alias, email, bundles: allOptionalBundles(), admin: input.admin === true };
55
+ return { ok: true, alias, email, bundles: allOptionalBundles(email), admin: input.admin === true };
60
56
  }
61
57
  const picked = CHECKBOX_BUNDLES.filter((b) => input[b] === true);
62
58
  const rawOther = (input.otherBundles ?? '').split(',').map((s) => s.trim()).filter(Boolean);
@@ -65,7 +61,7 @@ export function validateAddForm(input, existingAliases) {
65
61
  if (b === 'admin') {
66
62
  return { ok: false, slug: 'E_UNKNOWN_BUNDLE', message: '"admin" is not a scope bundle.', hint: 'Use the admin checkbox instead, or pass "admin": true.', alias };
67
63
  }
68
- if (!(b in BUNDLE_CATALOG)) {
64
+ if (!isKnownBundle(b)) {
69
65
  const closest = closestBundle(b);
70
66
  const known = Object.keys(BUNDLE_CATALOG).filter((n) => n !== 'admin').join(', ');
71
67
  return {
@@ -77,6 +73,16 @@ export function validateAddForm(input, existingAliases) {
77
73
  };
78
74
  }
79
75
  }
76
+ const refused = refusedBundlesFor(email, bundles);
77
+ if (refused.length > 0) {
78
+ return {
79
+ ok: false,
80
+ slug: 'E_VALIDATION',
81
+ message: `Google does not let a personal account grant ${refused.map((b) => `"${b}"`).join(', ')}.`,
82
+ hint: 'Leave it out, or link a Google Workspace account for it.',
83
+ alias,
84
+ };
85
+ }
80
86
  return { ok: true, alias, email, bundles, admin: input.admin === true };
81
87
  }
82
88
  /** URL-mode elicitation params. `elicitationId` is REQUIRED by the spec
@@ -109,8 +115,6 @@ export function scopeGrantDiff(requested, grantedScope) {
109
115
  const granted = new Set((grantedScope ?? '').split(' ').filter(Boolean));
110
116
  return requested.filter((s) => !granted.has(s));
111
117
  }
112
- /** Persist a new account row (+ a per-account scope profile when bundles/admin
113
- * were chosen) through the atomic registry mutation path (BR2). */
114
118
  function writeAccountRow(alias, email, bundles, admin) {
115
119
  mutateConfigFile((current) => {
116
120
  const next = { ...current, accounts: { ...(current.accounts ?? {}) } };
@@ -128,9 +132,8 @@ function writeAccountRow(alias, email, bundles, admin) {
128
132
  function textResult(text) {
129
133
  return { content: [{ type: 'text', text }] };
130
134
  }
131
- /** Wizard failures carry the same envelope as every other tool's, so a client
132
- * can branch on the slug and metrics can classify them. Success stays prose:
133
- * it is a human-readable onboarding report, not data. */
135
+ // Errors use the shared envelope so clients can branch on slug; success stays
136
+ // plain prose (a human-readable report, not data).
134
137
  function errorResult(slug, message, hint, account) {
135
138
  return {
136
139
  content: [{ type: 'text', text: stringifyEnvelope({
@@ -143,9 +146,24 @@ function errorResult(slug, message, hint, account) {
143
146
  isError: true,
144
147
  };
145
148
  }
146
- /** Run Google consent for `alias` and persist the token. Opens the browser via
147
- * URL-mode elicitation when the client supports it, else server-side + prints
148
- * the URL. Returns the granted-scope diff for the S4 report. */
149
+ let httpConsent = null;
150
+ export function setWizardHttpConsent(ctx) {
151
+ httpConsent = ctx;
152
+ }
153
+ // Services register tools at startup: an account's first-enabled service
154
+ // stays unregistered until a restart, so say so rather than claim it just works.
155
+ function restartNote(pending) {
156
+ if (pending.length === 0)
157
+ return '';
158
+ const names = pending.map((s) => `"${s}"`).join(', ');
159
+ const what = pending.length > 1 ? `services ${names} register their` : `service ${names} registers its`;
160
+ return `\nThe ${what} tools when the server starts: restart the server to use them. Until then, google_api_call can reach the same API.`;
161
+ }
162
+ function pendingText(alias, url, pending) {
163
+ return (`Account "${alias}" is ready to authorize. Open this link in a browser (it belongs to whoever owns the Google account):\n${url}\n` +
164
+ `On completion the token is stored server-side and the account is usable immediately — no restart. ` +
165
+ `If the link expires, run account_reauth for a fresh one.${restartNote(pending)}\n${TESTING_MODE_WARNING}`);
166
+ }
149
167
  async function runConsent(server, alias) {
150
168
  const { randomBytes } = await import('node:crypto');
151
169
  const cfg = getAccountSet().configs[alias];
@@ -157,9 +175,30 @@ async function runConsent(server, alias) {
157
175
  hint: 'Accounts sourced from GOOGLE_ACCOUNTS are not editable here. Run `mcp-google-multi migrate-config` to move them into config.json.',
158
176
  };
159
177
  }
160
- // Bind the ephemeral loopback listener BEFORE building the auth URL: the
161
- // redirect URI needs the assigned port, and listening first means the
162
- // callback can't race the browser.
178
+ // HTTP: consent completes out-of-band at the AS /callback; never bind a
179
+ // loopback listener; surface the URL up front, elicitation is just a convenience.
180
+ const http = httpConsent;
181
+ if (http) {
182
+ let url;
183
+ try {
184
+ url = await http.mintConsentUrl(alias);
185
+ }
186
+ catch (e) {
187
+ return { ok: false, slug: 'internal', message: safeMessage(e) };
188
+ }
189
+ const caps = server.server.getClientCapabilities?.();
190
+ if (caps?.elicitation?.url !== undefined) {
191
+ try {
192
+ await server.server.elicitInput(urlElicitationParams(alias, url, randomBytes(16).toString('hex')));
193
+ }
194
+ catch {
195
+ // pending text has the recovery URL
196
+ }
197
+ }
198
+ return { ok: 'pending', url };
199
+ }
200
+ // Bind the loopback listener before building the auth URL: the redirect URI
201
+ // needs the assigned port, and listening first avoids a race with the callback.
163
202
  const loop = await openLoopbackConsent();
164
203
  const client = buildConsentClient(loop.redirect);
165
204
  const expectedState = randomBytes(32).toString('hex');
@@ -172,9 +211,8 @@ async function runConsent(server, alias) {
172
211
  let opened = false;
173
212
  if (caps?.elicitation?.url !== undefined) {
174
213
  try {
175
- // elicitationId is REQUIRED by the spec schema; omitting it made every
176
- // url-mode request fail client-side validation, so this branch always
177
- // fell through to the server-side browser open.
214
+ // elicitationId is REQUIRED by the spec; omitting it silently fails
215
+ // client-side validation, falling through to the server-side browser open.
178
216
  const r = await server.server.elicitInput(urlElicitationParams(alias, url, randomBytes(16).toString('hex')));
179
217
  if (r.action !== 'accept') {
180
218
  loop.close();
@@ -211,22 +249,21 @@ async function runConsent(server, alias) {
211
249
  writeToken(alias, tokens);
212
250
  return { ok: true, missing: scopeGrantDiff(scopes, typeof tokens.scope === 'string' ? tokens.scope : undefined) };
213
251
  }
214
- function s4Text(alias, missing) {
252
+ function s4Text(alias, missing, pending) {
215
253
  const outcome = missing.length === 0
216
254
  ? `✔ "${alias}" authenticated; all requested scopes granted. It is now usable without a restart.`
217
255
  : `⚠ "${alias}" authenticated, but ${missing.length} requested scope(s) were NOT granted (E_SCOPE_NOT_GRANTED). You may have unchecked some on the consent screen. Re-run account_reauth to grant them. The account is usable for the granted scopes.`;
218
- return `${outcome}\n${TESTING_MODE_WARNING}`;
256
+ return `${outcome}${restartNote(pending)}\n${TESTING_MODE_WARNING}`;
219
257
  }
220
258
  const REQUIRES_INTERACTION = { 'anthropic/requiresUserInteraction': true };
221
- export function registerAccountWizardTools(registry, server) {
222
- // Registered as META (always-visible, like account_list) so onboarding tools
223
- // are reachable in lazy mode without discover_all first; registerMeta still
224
- // preserves the requiresUserInteraction clientMeta and skips the fan-out path.
259
+ export function registerAccountWizardTools(registry, server, awaitingRestart = () => []) {
260
+ // Registered as meta (like account_list) so onboarding tools are reachable in
261
+ // lazy mode without discover_all; this still preserves requiresUserInteraction and skips fan-out.
225
262
  const registerMeta = registry.registerMeta;
226
263
  registerMeta('account_add', {
227
264
  _meta: REQUIRES_INTERACTION,
228
265
  annotations: { openWorldHint: true },
229
- description: 'Add a new Google account: pass alias + email directly (plus optional bundles/allBundles/admin), or pass nothing for an interactive form where the client supports elicitation. Writes the registry and runs Google consent in the browser. No file editing or restart needed. Requires GOOGLE_CLIENT_ID/SECRET (run the `setup` prompt first if missing).',
266
+ description: 'Add a new Google account: pass alias + email directly (plus optional bundles/allBundles/admin), or pass nothing for an interactive form where the client supports elicitation. Writes the registry and runs Google consent in the browser. No file editing or restart needed to use the account (tools of a service it newly enables register at the next restart). Requires GOOGLE_CLIENT_ID/SECRET (run the `setup` prompt first if missing).',
230
267
  inputSchema: {
231
268
  alias: z.string().optional().describe('Account alias (letters, digits, _ or -). Pass with email to add directly, skipping the form.'),
232
269
  account: z.string().optional().describe('Alias for the new account (same as `alias`; every other tool spells it `account`)'),
@@ -237,18 +274,16 @@ export function registerAccountWizardTools(registry, server) {
237
274
  },
238
275
  }, async (args) => {
239
276
  try {
240
- // Env-sourced registry: GOOGLE_ACCOUNTS is the exclusive source and
241
- // config.json accounts are ignored, so a wizard add would be a phantom
242
- // write. Refuse up front (BR-10) rather than write a row nothing reads.
277
+ // GOOGLE_ACCOUNTS is the exclusive registry source when set; config.json
278
+ // is ignored, so refuse here rather than write a row nothing reads.
243
279
  if (process.env.GOOGLE_ACCOUNTS?.trim()) {
244
280
  return errorResult('E_ENV_ACCOUNTS_MODE', 'Accounts are defined by the GOOGLE_ACCOUNTS environment variable, so a new account cannot be added interactively: config.json is ignored while it is set, and the row would be written where nothing reads it.', 'Either add the alias to GOOGLE_ACCOUNTS and run account_reauth, or run `mcp-google-multi migrate-config` to move accounts into config.json and unset GOOGLE_ACCOUNTS.');
245
281
  }
246
282
  if (!hasClientCredentials()) {
247
283
  return errorResult('invalid_client', 'GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are not set, so there is no OAuth client to run consent against.', 'Run the `setup` prompt (/mcp__google-multi__setup) to create an OAuth client, then set them. See docs/google-cloud-setup.md.');
248
284
  }
249
- // S1: collect the registry row. Arguments win over the form so the
250
- // wizard still works in clients without form elicitation (where the
251
- // interactive path used to dead-end).
285
+ // Arguments win over the form so the wizard still works for clients
286
+ // without form elicitation support.
252
287
  const a = (args ?? {});
253
288
  let input;
254
289
  if (a.alias?.trim() || a.account?.trim() || a.email?.trim()) {
@@ -269,15 +304,23 @@ export function registerAccountWizardTools(registry, server) {
269
304
  const validated = validateAddForm(input, getAccountSet().aliases);
270
305
  if (!validated.ok)
271
306
  return errorResult(validated.slug, validated.message, validated.hint, validated.alias);
272
- // S2: atomic write + make the alias callable without a restart (BR3).
307
+ // Atomic write + make the alias callable without a restart.
273
308
  writeAccountRow(validated.alias, validated.email, validated.bundles, validated.admin);
274
309
  invalidateAccountSet();
275
- // S3 + S4: consent + validate.
310
+ // Account args already validate against the refreshed registry; the
311
+ // nudge just gets clients to re-fetch tools/list for the new account enum.
312
+ try {
313
+ server.sendToolListChanged();
314
+ }
315
+ catch {
316
+ // harmless if unsupported
317
+ }
276
318
  const consent = await runConsent(server, validated.alias);
319
+ if (consent.ok === 'pending')
320
+ return textResult(pendingText(validated.alias, consent.url, awaitingRestart()));
277
321
  if (!consent.ok)
278
322
  return errorResult(consent.slug, consent.message, consent.hint, validated.alias);
279
- // B15: offer to register the server with another MCP client.
280
- return textResult(`${s4Text(validated.alias, consent.missing)}\nTip: run account_write_config to register this server with another MCP client (Claude Desktop / Cursor / Claude Code).`);
323
+ return textResult(`${s4Text(validated.alias, consent.missing, awaitingRestart())}\nTip: run account_write_config to register this server with another MCP client (Claude Desktop / Cursor / Claude Code).`);
281
324
  }
282
325
  catch (e) {
283
326
  // safeMessage, not error.message: an arbitrary throw here can carry a
@@ -293,16 +336,17 @@ export function registerAccountWizardTools(registry, server) {
293
336
  // Plain string (NOT the account enum) so this never joins the
294
337
  // multi-account fan-out path; validated against the registry below.
295
338
  alias: z.string().min(1).optional().describe('Existing account alias to re-authenticate'),
296
- // Every other tool in the server spells this `account`, so that is
297
- // what a caller reaches for. Accepting both removes a dead end that
298
- // no did-you-mean can rescue: `account` is 5 edits from `alias`, so
299
- // the matcher cannot bridge them.
339
+ // Every other tool spells this `account`; accepting both avoids a dead
340
+ // end no did-you-mean can bridge (too many edits from `alias`).
300
341
  account: z.string().min(1).optional().describe('Alias of the account to re-authenticate (same as `alias`)'),
301
342
  },
302
343
  }, async (args) => {
303
344
  try {
304
345
  const a = args;
305
346
  const alias = a.alias ?? a.account ?? '';
347
+ // No default-account wrapper runs for this tool, so pick up a
348
+ // hand-edited config.json here: that edit is why a re-auth is asked for.
349
+ refreshAccountSetIfStale();
306
350
  if (!hasClientCredentials()) {
307
351
  return errorResult('invalid_client', 'GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are not set, so there is no OAuth client to run consent against.', 'Run the `setup` prompt (/mcp__google-multi__setup) to create an OAuth client, then set them. See docs/google-cloud-setup.md.');
308
352
  }
@@ -310,9 +354,11 @@ export function registerAccountWizardTools(registry, server) {
310
354
  return errorResult('validation_error', `Unknown account "${alias}".`, `Pass one of the configured aliases: ${getAccountSet().aliases.join(', ')}. Use account_add to create a new one.`, alias);
311
355
  }
312
356
  const consent = await runConsent(server, alias);
357
+ if (consent.ok === 'pending')
358
+ return textResult(pendingText(alias, consent.url, awaitingRestart()));
313
359
  if (!consent.ok)
314
360
  return errorResult(consent.slug, consent.message, consent.hint, alias);
315
- return textResult(s4Text(alias, consent.missing));
361
+ return textResult(s4Text(alias, consent.missing, awaitingRestart()));
316
362
  }
317
363
  catch (e) {
318
364
  return errorResult('internal', `account_reauth failed: ${safeMessage(e)}`);
@@ -340,7 +386,7 @@ export function registerAccountWizardTools(registry, server) {
340
386
  resourceUri = m.resourceUri;
341
387
  }
342
388
  catch {
343
- // stdio fallback on any config error
389
+ // fallback to stdio on error
344
390
  }
345
391
  let entry;
346
392
  try {
@@ -360,6 +406,11 @@ export function registerAccountWizardTools(registry, server) {
360
406
  if (client.managed === 'cli') {
361
407
  blocks.push(`${client.label}: run\n ${instr.text}`);
362
408
  }
409
+ else if (a.write && mode === 'http') {
410
+ // Over HTTP this runs on the daemon, not the caller's machine: write:true
411
+ // would edit the operator's own client configs (a tenancy hazard), so snippet only.
412
+ blocks.push(`${client.label}: write:true is ignored over HTTP — this tool runs on the server, not your machine. Add to ${instr.path}\n${instr.text}`);
413
+ }
363
414
  else if (a.write) {
364
415
  const res = applyFileEntry(client, name, entry);
365
416
  blocks.push(res.ok
@@ -1,5 +1,7 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
+ import { type AccountSet } from '../accounts.js';
2
3
  import { type ScopesReport } from '../scope-observability.js';
4
+ import { type TokenStore } from '../token-store.js';
3
5
  export interface AccountHealthDeps {
4
6
  hasToken: (alias: string) => boolean;
5
7
  readToken: (alias: string) => {
@@ -10,10 +12,15 @@ export interface AccountHealthDeps {
10
12
  fileExists: (p: string) => boolean;
11
13
  now: () => number;
12
14
  /** Scopes required by registered tools — feeds doctor's tool-grained
13
- * "registered but not requestable" view (B9). account_list stays compact:
15
+ * "registered but not requestable" view. account_list stays compact:
14
16
  * its report universe is profile ∪ granted only. */
15
17
  registeredScopes?: () => string[];
18
+ /** Account view health is derived against; absent = the global registry. */
19
+ accounts?: () => AccountSet;
16
20
  }
21
+ /** Health deps over one context's token store (and, optionally, its account
22
+ * view); the module-level functions are the single owner's store. */
23
+ export declare function accountHealthDepsFor(store: Pick<TokenStore, 'hasToken' | 'readToken'>, accounts?: () => AccountSet): AccountHealthDeps;
17
24
  export type TokenStatus = 'ok' | 'expired_refreshable' | 'needs_reauth' | 'missing' | 'decrypt_error';
18
25
  export interface AccountHealth {
19
26
  alias: string;
@@ -1,19 +1,28 @@
1
+ import { reauthHint } from '../reauth-hint.js';
1
2
  import * as fs from 'node:fs';
2
3
  import { getAccountSet, refreshAccountSetIfStale } from '../accounts.js';
3
4
  import { buildScopesReport } from '../scope-observability.js';
4
5
  import { getAdminAccounts, resolveScopesForAccount } from '../auth.js';
5
6
  import { hasToken, readToken } from '../token-store.js';
6
- const DEFAULT_DEPS = {
7
- hasToken,
8
- readToken,
9
- fileExists: fs.existsSync,
10
- now: Date.now,
11
- };
7
+ import { hints } from '../hint-provider.js';
8
+ /** Health deps over one context's token store (and, optionally, its account
9
+ * view); the module-level functions are the single owner's store. */
10
+ export function accountHealthDepsFor(store, accounts) {
11
+ return {
12
+ hasToken: store.hasToken,
13
+ readToken: store.readToken,
14
+ fileExists: fs.existsSync,
15
+ now: Date.now,
16
+ ...(accounts ? { accounts } : {}),
17
+ };
18
+ }
19
+ const DEFAULT_DEPS = accountHealthDepsFor({ hasToken, readToken });
12
20
  export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
13
- const config = getAccountSet().configs[alias];
14
- const admin = getAdminAccounts().includes(alias);
15
- const configured = resolveScopesForAccount(alias);
16
- // BR-10: env-sourced accounts are not persisted in config.json, so the
21
+ const set = (deps.accounts ?? getAccountSet)();
22
+ const config = set.configs[alias];
23
+ const admin = getAdminAccounts(set).includes(alias);
24
+ const configured = resolveScopesForAccount(alias, set);
25
+ // Env-sourced accounts are not persisted in config.json, so the
17
26
  // account wizard cannot edit them — the deployer edits env instead.
18
27
  const base = {
19
28
  alias,
@@ -29,13 +38,17 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
29
38
  const noScopes = buildScopesReport(new Set(), profileSet, registered);
30
39
  if (!deps.hasToken(alias)) {
31
40
  const legacy = deps.fileExists(config.tokenPath);
41
+ const rendered = legacy
42
+ ? hints()?.render({ kind: 'legacy_plaintext_token', account: alias })
43
+ : hints()?.render({ kind: 'reauth', account: alias });
32
44
  return {
33
45
  ...base,
34
46
  token: {
35
47
  status: 'missing',
36
- hint: legacy
37
- ? 'Plaintext token.json found — run: npx mcp-google-multi migrate-tokens'
38
- : `Run: npx mcp-google-multi auth --account ${alias}`,
48
+ hint: rendered?.hint ??
49
+ (legacy
50
+ ? 'Plaintext token.json found — run: npx mcp-google-multi migrate-tokens'
51
+ : reauthHint(alias)),
39
52
  },
40
53
  scopes: noScopes,
41
54
  };
@@ -45,9 +58,10 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
45
58
  token = deps.readToken(alias);
46
59
  }
47
60
  catch {
61
+ const rendered = hints()?.render({ kind: 'token_unreadable', account: alias });
48
62
  return {
49
63
  ...base,
50
- token: { status: 'decrypt_error', hint: 'Wrong MASTER_KEY or corrupt token file — re-auth or restore the key.' },
64
+ token: { status: 'decrypt_error', hint: rendered?.hint ?? 'Wrong MASTER_KEY or corrupt token file — re-auth or restore the key.' },
51
65
  scopes: noScopes,
52
66
  };
53
67
  }
@@ -69,9 +83,9 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
69
83
  status,
70
84
  expiryDate: expiry !== undefined ? new Date(expiry).toISOString() : undefined,
71
85
  hint: status === 'needs_reauth'
72
- ? `Run: npx mcp-google-multi auth --account ${alias}`
86
+ ? (hints()?.render({ kind: 'reauth', account: alias })?.hint ?? reauthHint(alias))
73
87
  : missing.length > 0
74
- ? 'Re-auth to grant the missing scopes'
88
+ ? (hints()?.render({ kind: 'scopes_missing', account: alias })?.hint ?? 'Re-auth to grant the missing scopes')
75
89
  : undefined,
76
90
  },
77
91
  scopes: report,
@@ -87,14 +101,16 @@ export function registerAccountTools(registry, deps = DEFAULT_DEPS) {
87
101
  _meta: { 'anthropic/alwaysLoad': true },
88
102
  }, async () => {
89
103
  refreshAccountSetIfStale();
104
+ const set = registry.accountSet();
105
+ const health = { ...deps, accounts: () => set };
90
106
  return {
91
107
  content: [
92
108
  {
93
109
  type: 'text',
94
110
  text: JSON.stringify({
95
- defaultAccount: getAccountSet().defaultAccount ?? null,
96
- defaultAccountSource: getAccountSet().defaultAccountSource ?? null,
97
- accounts: getAccountSet().aliases.map((alias) => deriveAccountHealth(alias, deps)),
111
+ defaultAccount: set.defaultAccount ?? null,
112
+ defaultAccountSource: set.defaultAccountSource ?? null,
113
+ accounts: set.aliases.map((alias) => deriveAccountHealth(alias, health)),
98
114
  }),
99
115
  },
100
116
  ],
@@ -1,2 +1,11 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
- export declare function registerAdminTools(server: ToolRegistry): void;
2
+ import type { Account } from '../accounts.js';
3
+ import { type CuratedToolDeps } from '../client.js';
4
+ export declare function registerAdminTools(server: ToolRegistry, deps?: CuratedToolDeps): void;
5
+ export declare function handleAdminError(error: any, account: Account): {
6
+ content: {
7
+ type: "text";
8
+ text: string;
9
+ }[];
10
+ isError: true;
11
+ };
@@ -1,12 +1,15 @@
1
1
  import { z } from 'zod';
2
2
  import { coerceBoolean } from './_coerce.js';
3
3
  import { admin as adminClient } from '@googleapis/admin';
4
- import { accountAliasSchema } from '../accounts.js';
4
+ import { accountArgLive } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError, invalidParams } from './_errors.js';
7
- const accountEnum = accountAliasSchema.optional();
8
7
  // Admin SDK requires Workspace super-admin (or delegated admin) on the account — personal @gmail.com accounts 403 on every endpoint.
9
- export function registerAdminTools(server) {
8
+ export function registerAdminTools(server, deps = {}) {
9
+ // Per-registry, live account enum + injectable client: the schema follows
10
+ // the registry's account view at parse time, not a process-global default.
11
+ const accountEnum = accountArgLive(() => server.accountAliases()).optional();
12
+ const getClientFn = deps.getClientFn ?? getClient;
10
13
  // ─── Reports / audit log ───────────────────────────────────────────────
11
14
  server.registerTool('reports_activities_list', {
12
15
  description: 'List Admin Activity audit log entries. Filter by application (login, drive, gmail, admin, token, etc.), user, date range, event. The single most useful admin endpoint for SMB visibility.',
@@ -34,7 +37,7 @@ export function registerAdminTools(server) {
34
37
  },
35
38
  }, async ({ account, applicationName, userKey, startTime, endTime, eventName, actorIpAddress, filters, orgUnitID, groupIdFilter, customerId, maxResults, pageToken }) => {
36
39
  try {
37
- const auth = await getClient(account);
40
+ const auth = await getClientFn(account);
38
41
  const reports = adminClient({ version: 'reports_v1', auth });
39
42
  const res = await reports.activities.list({
40
43
  applicationName,
@@ -74,7 +77,7 @@ export function registerAdminTools(server) {
74
77
  },
75
78
  }, async ({ account, customer, domain, query, maxResults, pageToken, orderBy, showDeleted, projection }) => {
76
79
  try {
77
- const auth = await getClient(account);
80
+ const auth = await getClientFn(account);
78
81
  const directory = adminClient({ version: 'directory_v1', auth });
79
82
  const res = await directory.users.list({
80
83
  customer: customer ?? 'my_customer',
@@ -103,7 +106,7 @@ export function registerAdminTools(server) {
103
106
  },
104
107
  }, async ({ account, userKey, projection }) => {
105
108
  try {
106
- const auth = await getClient(account);
109
+ const auth = await getClientFn(account);
107
110
  const directory = adminClient({ version: 'directory_v1', auth });
108
111
  const res = await directory.users.get({
109
112
  userKey,
@@ -131,7 +134,7 @@ export function registerAdminTools(server) {
131
134
  },
132
135
  }, async ({ account, userKey, givenName, familyName, suspended, password, changePasswordAtNextLogin, orgUnitPath }) => {
133
136
  try {
134
- const auth = await getClient(account);
137
+ const auth = await getClientFn(account);
135
138
  const directory = adminClient({ version: 'directory_v1', auth });
136
139
  const requestBody = {};
137
140
  if (givenName !== undefined || familyName !== undefined) {
@@ -175,7 +178,7 @@ export function registerAdminTools(server) {
175
178
  },
176
179
  }, async ({ account, customer, domain, userKey, query, maxResults, pageToken }) => {
177
180
  try {
178
- const auth = await getClient(account);
181
+ const auth = await getClientFn(account);
179
182
  const directory = adminClient({ version: 'directory_v1', auth });
180
183
  const res = await directory.groups.list({
181
184
  customer: customer ?? 'my_customer',
@@ -205,7 +208,7 @@ export function registerAdminTools(server) {
205
208
  },
206
209
  }, async ({ account, groupKey, roles, includeDerivedMembership, maxResults, pageToken }) => {
207
210
  try {
208
- const auth = await getClient(account);
211
+ const auth = await getClientFn(account);
209
212
  const directory = adminClient({ version: 'directory_v1', auth });
210
213
  const res = await directory.members.list({
211
214
  groupKey,
@@ -223,7 +226,7 @@ export function registerAdminTools(server) {
223
226
  }
224
227
  });
225
228
  }
226
- function handleAdminError(error, account) {
229
+ export function handleAdminError(error, account) {
227
230
  const admin = 'Admin tools require Workspace super-admin privileges AND the account must be listed as an admin account (then re-authenticated). Personal Gmail accounts cannot use these endpoints.';
228
- return handleGoogleApiError(error, account, { scope: admin, resource: admin });
231
+ return handleGoogleApiError(error, account, { scope: admin, resource: admin, bundle: 'admin' });
229
232
  }
@@ -1,4 +1,6 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
+ import type { Account } from '../accounts.js';
3
+ import { type CuratedToolDeps } from '../client.js';
2
4
  /** Accepts "213025502" or "properties/213025502"; rejects the identifiers
3
5
  * people paste by mistake (G-… measurement IDs, UA-… properties) with a
4
6
  * pointer to the right one. */
@@ -15,4 +17,11 @@ export declare function shapeAccountSummaries(data: any): Record<string, unknown
15
17
  /** Full metadata descriptions run to paragraphs; the first ~160 chars carry
16
18
  * the disambiguation the model needs without bloating a ~300-entry list. */
17
19
  export declare function shapeMetadata(data: any): Record<string, unknown>;
18
- export declare function registerAnalyticsTools(server: ToolRegistry): void;
20
+ export declare function registerAnalyticsTools(server: ToolRegistry, deps?: CuratedToolDeps): void;
21
+ export declare function handleAnalyticsError(error: any, account: Account): {
22
+ content: {
23
+ type: "text";
24
+ text: string;
25
+ }[];
26
+ isError: true;
27
+ };
@@ -2,10 +2,9 @@ import { z } from 'zod';
2
2
  import { coerceArray, coerceBoolean, coerceJson } from './_coerce.js';
3
3
  import { analyticsdata as analyticsdataClient } from '@googleapis/analyticsdata';
4
4
  import { analyticsadmin as analyticsadminClient } from '@googleapis/analyticsadmin';
5
- import { accountAliasSchema } from '../accounts.js';
5
+ import { accountArgLive } from '../accounts.js';
6
6
  import { getClient } from '../client.js';
7
7
  import { handleGoogleApiError } from './_errors.js';
8
- const accountEnum = accountAliasSchema.optional();
9
8
  /** Accepts "213025502" or "properties/213025502"; rejects the identifiers
10
9
  * people paste by mistake (G-… measurement IDs, UA-… properties) with a
11
10
  * pointer to the right one. */
@@ -105,7 +104,11 @@ export function shapeMetadata(data) {
105
104
  const propertySchema = z
106
105
  .string()
107
106
  .describe('GA4 property: numeric ID like "213025502" or "properties/213025502" — NOT a "G-..." measurement ID and not "UA-...". Find yours with analytics_account_summaries.');
108
- export function registerAnalyticsTools(server) {
107
+ export function registerAnalyticsTools(server, deps = {}) {
108
+ // Per-registry, live account enum + injectable client: the schema follows
109
+ // the registry's account view at parse time, not a process-global default.
110
+ const accountEnum = accountArgLive(() => server.accountAliases()).optional();
111
+ const getClientFn = deps.getClientFn ?? getClient;
109
112
  server.registerTool('analytics_account_summaries', {
110
113
  description: 'List every Google Analytics (GA4) account and property this Google account can access, with their numeric property IDs. The starting point for any Analytics question ("what properties do I have?").',
111
114
  inputSchema: {
@@ -115,7 +118,7 @@ export function registerAnalyticsTools(server) {
115
118
  },
116
119
  }, async ({ account, pageSize, pageToken }) => {
117
120
  try {
118
- const auth = await getClient(account);
121
+ const auth = await getClientFn(account);
119
122
  const admin = analyticsadminClient({ version: 'v1beta', auth });
120
123
  const res = await admin.accountSummaries.list({ pageSize, pageToken });
121
124
  return {
@@ -159,7 +162,7 @@ export function registerAnalyticsTools(server) {
159
162
  if ('hint' in prop)
160
163
  return invalidProperty(prop.hint, account);
161
164
  try {
162
- const auth = await getClient(account);
165
+ const auth = await getClientFn(account);
163
166
  const dataApi = analyticsdataClient({ version: 'v1beta', auth });
164
167
  const requestBody = {
165
168
  dateRanges: [{ startDate, endDate }],
@@ -216,7 +219,7 @@ export function registerAnalyticsTools(server) {
216
219
  if ('hint' in prop)
217
220
  return invalidProperty(prop.hint, account);
218
221
  try {
219
- const auth = await getClient(account);
222
+ const auth = await getClientFn(account);
220
223
  const dataApi = analyticsdataClient({ version: 'v1beta', auth });
221
224
  const requestBody = { metrics: metrics.map((name) => ({ name })) };
222
225
  if (dimensions?.length)
@@ -251,7 +254,7 @@ export function registerAnalyticsTools(server) {
251
254
  if ('hint' in prop)
252
255
  return invalidProperty(prop.hint, account);
253
256
  try {
254
- const auth = await getClient(account);
257
+ const auth = await getClientFn(account);
255
258
  const dataApi = analyticsdataClient({ version: 'v1beta', auth });
256
259
  const res = await dataApi.properties.getMetadata({ name: `${prop.name}/metadata` });
257
260
  return {
@@ -274,11 +277,12 @@ function invalidProperty(hint, account) {
274
277
  isError: true,
275
278
  };
276
279
  }
277
- function handleAnalyticsError(error, account) {
280
+ export function handleAnalyticsError(error, account) {
278
281
  return handleGoogleApiError(error, account, {
279
282
  scope: 'Needs the "analytics" bundle on this account: add it to the scope profile, then re-auth.',
280
- // GA4 answers PERMISSION_DENIED for a property ACL too, and the old
281
- // combined hint sent those callers to re-auth a scope they already had.
283
+ // GA4 returns PERMISSION_DENIED for a property-access issue too, not just
284
+ // a missing scope; keep the hints distinct, or callers get sent to re-auth a scope they already have.
282
285
  resource: 'The scope is not the problem: this Google account has no access to that GA4 property. Grant it in GA4 Admin > Property Access Management, or call analytics_account_summaries to see the properties it can read.',
286
+ bundle: 'analytics',
283
287
  });
284
288
  }
@@ -1,5 +1,6 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
- export declare function registerCalendarTools(server: ToolRegistry): void;
2
+ import { type CuratedToolDeps } from '../client.js';
3
+ export declare function registerCalendarTools(server: ToolRegistry, deps?: CuratedToolDeps): void;
3
4
  /** Google disagrees with itself on a backwards window: events.list answers 200
4
5
  * with [], which reads as "nothing scheduled", while freebusy.query 400s on the
5
6
  * identical input. Decide locally so an empty list always means empty. Returns