mcp-google-multi 6.0.0-beta.1 → 6.0.0-beta.2

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 (103) hide show
  1. package/README.md +5 -3
  2. package/dist/accounts.d.ts +5 -0
  3. package/dist/accounts.js +12 -1
  4. package/dist/api-probe.js +3 -0
  5. package/dist/arg-normalize.d.ts +61 -4
  6. package/dist/arg-normalize.js +228 -4
  7. package/dist/arg-strict.d.ts +54 -0
  8. package/dist/arg-strict.js +163 -0
  9. package/dist/auth.js +15 -72
  10. package/dist/client.js +15 -5
  11. package/dist/config-file.d.ts +1 -0
  12. package/dist/config-file.js +4 -0
  13. package/dist/discover.js +35 -14
  14. package/dist/discovery-client.d.ts +8 -1
  15. package/dist/discovery-client.js +96 -17
  16. package/dist/doctor.d.ts +9 -1
  17. package/dist/doctor.js +32 -4
  18. package/dist/env-load.d.ts +11 -0
  19. package/dist/env-load.js +23 -1
  20. package/dist/executor.js +12 -1
  21. package/dist/fanout.js +43 -8
  22. package/dist/http-transport.d.ts +11 -2
  23. package/dist/http-transport.js +10 -5
  24. package/dist/index.js +112 -12
  25. package/dist/metrics-cli.d.ts +21 -0
  26. package/dist/metrics-cli.js +206 -0
  27. package/dist/metrics-tap.d.ts +12 -0
  28. package/dist/metrics-tap.js +95 -0
  29. package/dist/oauth-consent.d.ts +25 -10
  30. package/dist/oauth-consent.js +85 -39
  31. package/dist/outbound-allowlist.d.ts +34 -0
  32. package/dist/outbound-allowlist.js +108 -0
  33. package/dist/registry.d.ts +49 -2
  34. package/dist/registry.js +147 -7
  35. package/dist/scope-catalog.js +16 -0
  36. package/dist/services.d.ts +8 -0
  37. package/dist/services.js +28 -1
  38. package/dist/setup-prompt.d.ts +1 -1
  39. package/dist/setup-prompt.js +1 -1
  40. package/dist/tools/_coerce.d.ts +5 -0
  41. package/dist/tools/_coerce.js +11 -0
  42. package/dist/tools/_errors.d.ts +31 -3
  43. package/dist/tools/_errors.js +307 -43
  44. package/dist/tools/_local-files.js +7 -2
  45. package/dist/tools/account-wizard.d.ts +24 -1
  46. package/dist/tools/account-wizard.js +152 -51
  47. package/dist/tools/admin.js +7 -6
  48. package/dist/tools/analytics.d.ts +18 -0
  49. package/dist/tools/analytics.js +284 -0
  50. package/dist/tools/calendar.d.ts +7 -0
  51. package/dist/tools/calendar.js +75 -15
  52. package/dist/tools/chat.js +7 -4
  53. package/dist/tools/contacts.js +57 -30
  54. package/dist/tools/docs.js +56 -54
  55. package/dist/tools/drive.js +132 -70
  56. package/dist/tools/forms.js +7 -7
  57. package/dist/tools/generated/_shared.d.ts +6 -0
  58. package/dist/tools/generated/_shared.js +11 -1
  59. package/dist/tools/generated/admin.js +345 -214
  60. package/dist/tools/generated/analytics.d.ts +2 -0
  61. package/dist/tools/generated/analytics.js +981 -0
  62. package/dist/tools/generated/appsmarket.js +4 -4
  63. package/dist/tools/generated/calendar.js +28 -28
  64. package/dist/tools/generated/chat.js +83 -56
  65. package/dist/tools/generated/classroom.js +258 -216
  66. package/dist/tools/generated/cloudidentity.js +76 -68
  67. package/dist/tools/generated/cloudsearch.js +40 -39
  68. package/dist/tools/generated/contacts.js +20 -11
  69. package/dist/tools/generated/drive.js +62 -53
  70. package/dist/tools/generated/drivelabels.js +60 -32
  71. package/dist/tools/generated/forms.js +7 -7
  72. package/dist/tools/generated/gmail.js +136 -109
  73. package/dist/tools/generated/groupsmigration.js +1 -1
  74. package/dist/tools/generated/groupssettings.js +3 -3
  75. package/dist/tools/generated/index.js +2 -0
  76. package/dist/tools/generated/keep.js +8 -7
  77. package/dist/tools/generated/licensing.js +39 -23
  78. package/dist/tools/generated/meet.js +14 -14
  79. package/dist/tools/generated/method-map.d.ts +2 -0
  80. package/dist/tools/generated/method-map.js +894 -0
  81. package/dist/tools/generated/postmaster.js +3 -3
  82. package/dist/tools/generated/reseller.js +34 -28
  83. package/dist/tools/generated/script.js +29 -19
  84. package/dist/tools/generated/searchconsole.js +5 -3
  85. package/dist/tools/generated/sheets.js +10 -9
  86. package/dist/tools/generated/tasks.js +10 -4
  87. package/dist/tools/generated/vault.js +65 -56
  88. package/dist/tools/generated/workspaceevents.js +17 -16
  89. package/dist/tools/gmail.d.ts +10 -1
  90. package/dist/tools/gmail.js +50 -18
  91. package/dist/tools/google-api.js +48 -19
  92. package/dist/tools/meet.js +5 -2
  93. package/dist/tools/searchconsole.js +10 -10
  94. package/dist/tools/sheets.js +36 -48
  95. package/dist/tools/slides.js +6 -6
  96. package/dist/tools/tasks.js +16 -16
  97. package/dist/trim.d.ts +21 -0
  98. package/dist/trim.js +47 -0
  99. package/dist/usage-metrics.d.ts +155 -0
  100. package/dist/usage-metrics.js +603 -0
  101. package/dist/write-control.d.ts +27 -1
  102. package/dist/write-control.js +90 -11
  103. package/package.json +8 -3
@@ -5,14 +5,16 @@ import { writeToken } from '../token-store.js';
5
5
  import { resolveScopesForAccount } from '../auth.js';
6
6
  import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from '../scope-catalog.js';
7
7
  import { openUrl } from '../open-url.js';
8
- import { buildConsentClient, awaitLoopbackConsent, hasClientCredentials, } from '../oauth-consent.js';
8
+ import { coerceBoolean } from './_coerce.js';
9
+ import { safeMessage, stringifyEnvelope } from './_errors.js';
10
+ import { buildConsentClient, openLoopbackConsent, hasClientCredentials, TESTING_MODE_WARNING, } from '../oauth-consent.js';
9
11
  import { detectClients, buildServerEntry, renderInstruction, applyFileEntry, resolveMode, DEFAULT_SERVER_NAME, } from '../client-config.js';
10
12
  // B7: the elicitation-driven account_add / account_reauth wizard. It rebuilds
11
13
  // interactive account management on the mutable config.json registry so a
12
14
  // running server can add an account without the deployer editing env + running
13
15
  // a CLI. Google consent reuses the loopback flow (leg C); the HTTP-transport
14
16
  // consent path (${BASE}/authorize) is owned by the OAuth AS and lands with that
15
- // cluster — account_add over http is gated behind it.
17
+ // cluster. account_add over http is gated behind it.
16
18
  const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
17
19
  // The common optional bundles get their own checkbox (MCP elicitation has no
18
20
  // multi-select, so a boolean per bundle IS the checklist); the long tail stays
@@ -44,13 +46,13 @@ export function validateAddForm(input, existingAliases) {
44
46
  const alias = (input.alias ?? '').trim();
45
47
  const email = (input.email ?? '').trim();
46
48
  if (!ALIAS_RE.test(alias)) {
47
- return { ok: false, slug: 'E_VALIDATION', message: `Invalid alias "${alias}". Use letters, digits, "_" or "-".` };
49
+ return { ok: false, slug: 'E_VALIDATION', message: `Invalid alias "${alias}".`, hint: 'Use letters, digits, "_" or "-".', alias };
48
50
  }
49
51
  if (existingAliases.includes(alias)) {
50
- return { ok: false, slug: 'E_ALIAS_EXISTS', message: `Alias "${alias}" already exists; use account_reauth to re-authenticate it, or pick another name.` };
52
+ return { ok: false, slug: 'E_ALIAS_EXISTS', message: `Alias "${alias}" already exists.`, hint: 'Use account_reauth to re-authenticate it, or pick another name.', alias };
51
53
  }
52
54
  if (email === '') {
53
- return { ok: false, slug: 'E_VALIDATION', message: 'Email is required (used as the Google login hint).' };
55
+ 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 };
54
56
  }
55
57
  if (input.allBundles === true) {
56
58
  // "All optional scopes" supersedes the individual picks.
@@ -61,15 +63,46 @@ export function validateAddForm(input, existingAliases) {
61
63
  const bundles = [...new Set(resolveBundleAliases([...picked, ...rawOther]))];
62
64
  for (const b of bundles) {
63
65
  if (b === 'admin') {
64
- return { ok: false, slug: 'E_UNKNOWN_BUNDLE', message: '"admin" is not a bundle — use the admin checkbox instead.' };
66
+ 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 };
65
67
  }
66
68
  if (!(b in BUNDLE_CATALOG)) {
67
- const hint = closestBundle(b);
68
- return { ok: false, slug: 'E_UNKNOWN_BUNDLE', message: `Unknown bundle "${b}"${hint ? ` — did you mean "${hint}"?` : ''}. Known: ${Object.keys(BUNDLE_CATALOG).filter((n) => n !== 'admin').join(', ')}.` };
69
+ const closest = closestBundle(b);
70
+ const known = Object.keys(BUNDLE_CATALOG).filter((n) => n !== 'admin').join(', ');
71
+ return {
72
+ ok: false,
73
+ slug: 'E_UNKNOWN_BUNDLE',
74
+ message: `Unknown bundle "${b}".`,
75
+ hint: `${closest ? `Did you mean "${closest}"? ` : ''}Known bundles: ${known}.`,
76
+ alias,
77
+ };
69
78
  }
70
79
  }
71
80
  return { ok: true, alias, email, bundles, admin: input.admin === true };
72
81
  }
82
+ /** URL-mode elicitation params. `elicitationId` is REQUIRED by the spec
83
+ * schema; omitting it made every url-mode request fail client-side
84
+ * validation, so the branch silently never worked. Pure, so the shape is
85
+ * testable against the SDK's own schema without a live client. */
86
+ export function urlElicitationParams(alias, url, elicitationId) {
87
+ return {
88
+ mode: 'url',
89
+ elicitationId,
90
+ message: `Authorize the "${alias}" Google account in your browser.`,
91
+ url,
92
+ };
93
+ }
94
+ /** Map direct tool arguments onto the elicitation form shape: the argument-
95
+ * mode fallback for clients without form elicitation. All bundle picks travel
96
+ * through otherBundles, which validateAddForm resolves and validates. Pure. */
97
+ export function argsToAddForm(a) {
98
+ return {
99
+ alias: a.alias ?? a.account ?? '',
100
+ email: a.email ?? '',
101
+ allBundles: a.allBundles === true,
102
+ otherBundles: a.bundles ?? '',
103
+ admin: a.admin === true,
104
+ };
105
+ }
73
106
  /** Scopes requested by the profile but NOT granted at consent (granular
74
107
  * consent / unchecked bundles). Pure. */
75
108
  export function scopeGrantDiff(requested, grantedScope) {
@@ -92,8 +125,23 @@ function writeAccountRow(alias, email, bundles, admin) {
92
125
  return next;
93
126
  });
94
127
  }
95
- function textResult(text, isError = false) {
96
- return { content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) };
128
+ function textResult(text) {
129
+ return { content: [{ type: 'text', text }] };
130
+ }
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. */
134
+ function errorResult(slug, message, hint, account) {
135
+ return {
136
+ content: [{ type: 'text', text: stringifyEnvelope({
137
+ error: slug,
138
+ message,
139
+ ...(hint ? { hint } : {}),
140
+ retriable: false,
141
+ ...(account ? { account } : {}),
142
+ }) }],
143
+ isError: true,
144
+ };
97
145
  }
98
146
  /** Run Google consent for `alias` and persist the token. Opens the browser via
99
147
  * URL-mode elicitation when the client supports it, else server-side + prints
@@ -101,22 +149,41 @@ function textResult(text, isError = false) {
101
149
  async function runConsent(server, alias) {
102
150
  const { randomBytes } = await import('node:crypto');
103
151
  const cfg = getAccountSet().configs[alias];
104
- if (!cfg)
105
- return { ok: false, text: `E_VALIDATION: account "${alias}" is not in the live registry (env-sourced accounts are not editable here).` };
106
- const client = buildConsentClient();
152
+ if (!cfg) {
153
+ return {
154
+ ok: false,
155
+ slug: 'E_VALIDATION',
156
+ message: `Account "${alias}" is not in the live registry.`,
157
+ hint: 'Accounts sourced from GOOGLE_ACCOUNTS are not editable here. Run `mcp-google-multi migrate-config` to move them into config.json.',
158
+ };
159
+ }
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.
163
+ const loop = await openLoopbackConsent();
164
+ const client = buildConsentClient(loop.redirect);
107
165
  const expectedState = randomBytes(32).toString('hex');
108
166
  const scopes = resolveScopesForAccount(alias);
109
167
  const url = client.generateAuthUrl({ access_type: 'offline', prompt: 'consent', scope: scopes, login_hint: cfg.email, state: expectedState });
110
- // Start the loopback listener BEFORE opening the browser so it can't miss the
111
- // redirect. Any startup error (e.g. port in use) surfaces synchronously.
112
- const consent = awaitLoopbackConsent(client, expectedState);
168
+ const consent = loop.finish(client, expectedState);
169
+ // Capability probe: the spec advertises each elicitation mode as a PRESENT
170
+ // object, not a boolean, so test presence rather than truthiness.
113
171
  const caps = server.server.getClientCapabilities?.();
114
172
  let opened = false;
115
- if (caps?.elicitation?.url) {
173
+ if (caps?.elicitation?.url !== undefined) {
116
174
  try {
117
- const r = await server.server.elicitInput({ mode: 'url', message: `Authorize the "${alias}" Google account in your browser.`, url });
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.
178
+ const r = await server.server.elicitInput(urlElicitationParams(alias, url, randomBytes(16).toString('hex')));
118
179
  if (r.action !== 'accept') {
119
- return { ok: false, text: 'confirmation_declined: consent was cancelled; the account row was kept but no token was stored (doctor will show it as "missing").' };
180
+ loop.close();
181
+ return {
182
+ ok: false,
183
+ slug: 'confirmation_declined',
184
+ message: 'Consent was cancelled, so no token was stored.',
185
+ hint: 'The account row was kept and diagnose reports its token as "missing". Run account_reauth to finish authorizing it.',
186
+ };
120
187
  }
121
188
  opened = true;
122
189
  }
@@ -132,15 +199,23 @@ async function runConsent(server, alias) {
132
199
  tokens = await consent;
133
200
  }
134
201
  catch (e) {
135
- return { ok: false, text: `${e.message}${opened ? '' : `\nOpen this URL to authorize:\n${url}`}` };
202
+ // Always surface the URL: the browser hand-off can succeed and consent
203
+ // still fail (declined, timed out), and the URL is the only recovery.
204
+ return {
205
+ ok: false,
206
+ slug: 'auth_required',
207
+ message: safeMessage(e),
208
+ hint: `The browser hand-off can succeed and consent still fail, so this URL is the only recovery. Open it to authorize: ${url}`,
209
+ };
136
210
  }
137
211
  writeToken(alias, tokens);
138
212
  return { ok: true, missing: scopeGrantDiff(scopes, typeof tokens.scope === 'string' ? tokens.scope : undefined) };
139
213
  }
140
214
  function s4Text(alias, missing) {
141
- if (missing.length === 0)
142
- return `✔ "${alias}" authenticated; all requested scopes granted. It is now usable without a restart.`;
143
- return `⚠ "${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.`;
215
+ const outcome = missing.length === 0
216
+ ? `✔ "${alias}" authenticated; all requested scopes granted. It is now usable without a restart.`
217
+ : `⚠ "${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}`;
144
219
  }
145
220
  const REQUIRES_INTERACTION = { 'anthropic/requiresUserInteraction': true };
146
221
  export function registerAccountWizardTools(registry, server) {
@@ -151,43 +226,63 @@ export function registerAccountWizardTools(registry, server) {
151
226
  registerMeta('account_add', {
152
227
  _meta: REQUIRES_INTERACTION,
153
228
  annotations: { openWorldHint: true },
154
- description: 'Add a new Google account interactively: collects alias/email/scope bundles via a form, 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).',
155
- inputSchema: {},
156
- }, async () => {
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).',
230
+ inputSchema: {
231
+ alias: z.string().optional().describe('Account alias (letters, digits, _ or -). Pass with email to add directly, skipping the form.'),
232
+ account: z.string().optional().describe('Alias for the new account (same as `alias`; every other tool spells it `account`)'),
233
+ email: z.string().optional().describe("The account's Google address (used as the login hint)"),
234
+ bundles: z.string().optional().describe('Optional scope bundles, comma-separated (e.g. "forms,chat"); blank = base scopes only'),
235
+ allBundles: coerceBoolean.optional().describe('Grant every optional bundle (biggest consent screen); overrides bundles'),
236
+ admin: coerceBoolean.optional().describe('Grant Workspace admin scopes (super-admin accounts only)'),
237
+ },
238
+ }, async (args) => {
157
239
  try {
158
240
  // Env-sourced registry: GOOGLE_ACCOUNTS is the exclusive source and
159
241
  // config.json accounts are ignored, so a wizard add would be a phantom
160
242
  // write. Refuse up front (BR-10) rather than write a row nothing reads.
161
243
  if (process.env.GOOGLE_ACCOUNTS?.trim()) {
162
- return textResult('E_ENV_ACCOUNTS_MODE: accounts are defined by the GOOGLE_ACCOUNTS environment variable, so new accounts cannot be added interactively (config.json is ignored while it is set). 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.', true);
244
+ 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.');
163
245
  }
164
246
  if (!hasClientCredentials()) {
165
- return textResult('E_CLIENT_CREDENTIALS_MISSING: GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET are not set. Run the `setup` prompt (/mcp__google-multi__setup) to create an OAuth client, then set them.', true);
247
+ 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.');
166
248
  }
167
- const caps = server.server.getClientCapabilities?.();
168
- if (!caps?.elicitation?.form) {
169
- return textResult('This client does not support form elicitation. Add the account from the CLI instead: `npx mcp-google-multi account add --alias <alias> --email <email> [--profile a,b] [--admin]`.', true);
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).
252
+ const a = (args ?? {});
253
+ let input;
254
+ if (a.alias?.trim() || a.account?.trim() || a.email?.trim()) {
255
+ input = argsToAddForm(a);
170
256
  }
171
- // S1: collect the registry row.
172
- const form = await server.server.elicitInput({ message: 'Add a Google account', requestedSchema: addFormSchema() });
173
- if (form.action !== 'accept') {
174
- return textResult('confirmation_declined: no account was added.');
257
+ else {
258
+ // Modes are advertised as PRESENT objects, not booleans.
259
+ const caps = server.server.getClientCapabilities?.();
260
+ if (caps?.elicitation?.form === undefined) {
261
+ return errorResult('elicitation_unsupported', 'This client does not support the interactive form, and no arguments were supplied, so there is nothing to add.', 'Call account_add again with arguments instead, e.g. {"alias": "work", "email": "you@example.com"} (optional: "bundles" as a comma-separated list, "allBundles": true, "admin": true).');
262
+ }
263
+ const form = await server.server.elicitInput({ message: 'Add a Google account', requestedSchema: addFormSchema() });
264
+ if (form.action !== 'accept') {
265
+ return textResult('confirmation_declined: no account was added.');
266
+ }
267
+ input = (form.content ?? {});
175
268
  }
176
- const validated = validateAddForm(form.content ?? {}, getAccountSet().aliases);
269
+ const validated = validateAddForm(input, getAccountSet().aliases);
177
270
  if (!validated.ok)
178
- return textResult(`${validated.slug}: ${validated.message}`, true);
271
+ return errorResult(validated.slug, validated.message, validated.hint, validated.alias);
179
272
  // S2: atomic write + make the alias callable without a restart (BR3).
180
273
  writeAccountRow(validated.alias, validated.email, validated.bundles, validated.admin);
181
274
  invalidateAccountSet();
182
275
  // S3 + S4: consent + validate.
183
276
  const consent = await runConsent(server, validated.alias);
184
277
  if (!consent.ok)
185
- return textResult(consent.text, true);
278
+ return errorResult(consent.slug, consent.message, consent.hint, validated.alias);
186
279
  // B15: offer to register the server with another MCP client.
187
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).`);
188
281
  }
189
282
  catch (e) {
190
- return textResult(`account_add failed: ${e.message}`, true);
283
+ // safeMessage, not error.message: an arbitrary throw here can carry a
284
+ // token or a whole response body.
285
+ return errorResult('internal', `account_add failed: ${safeMessage(e)}`);
191
286
  }
192
287
  });
193
288
  registerMeta('account_reauth', {
@@ -197,24 +292,30 @@ export function registerAccountWizardTools(registry, server) {
197
292
  inputSchema: {
198
293
  // Plain string (NOT the account enum) so this never joins the
199
294
  // multi-account fan-out path; validated against the registry below.
200
- alias: z.string().describe('Existing account alias to re-authenticate'),
295
+ 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.
300
+ account: z.string().min(1).optional().describe('Alias of the account to re-authenticate (same as `alias`)'),
201
301
  },
202
302
  }, async (args) => {
203
303
  try {
204
- const alias = args.alias ?? '';
304
+ const a = args;
305
+ const alias = a.alias ?? a.account ?? '';
205
306
  if (!hasClientCredentials()) {
206
- return textResult('E_CLIENT_CREDENTIALS_MISSING: GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET are not set.', true);
307
+ 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.');
207
308
  }
208
309
  if (!getAccountSet().aliases.includes(alias)) {
209
- return textResult(`E_VALIDATION: unknown account "${alias}". Known: ${getAccountSet().aliases.join(', ')}. Use account_add for a new one.`, true);
310
+ 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);
210
311
  }
211
312
  const consent = await runConsent(server, alias);
212
313
  if (!consent.ok)
213
- return textResult(consent.text, true);
314
+ return errorResult(consent.slug, consent.message, consent.hint, alias);
214
315
  return textResult(s4Text(alias, consent.missing));
215
316
  }
216
317
  catch (e) {
217
- return textResult(`account_reauth failed: ${e.message}`, true);
318
+ return errorResult('internal', `account_reauth failed: ${safeMessage(e)}`);
218
319
  }
219
320
  });
220
321
  registerMeta('account_write_config', {
@@ -246,7 +347,7 @@ export function registerAccountWizardTools(registry, server) {
246
347
  entry = buildServerEntry({ name, mode, resourceUri });
247
348
  }
248
349
  catch (e) {
249
- return textResult(e.message, true);
350
+ return errorResult('internal', safeMessage(e));
250
351
  }
251
352
  let clients = detectClients();
252
353
  if (a.client)
@@ -257,16 +358,16 @@ export function registerAccountWizardTools(registry, server) {
257
358
  for (const client of targets) {
258
359
  const instr = renderInstruction(client, name, entry);
259
360
  if (client.managed === 'cli') {
260
- blocks.push(`${client.label} — run:\n ${instr.text}`);
361
+ blocks.push(`${client.label}: run\n ${instr.text}`);
261
362
  }
262
363
  else if (a.write) {
263
364
  const res = applyFileEntry(client, name, entry);
264
365
  blocks.push(res.ok
265
- ? `${client.label} — ${res.action} "${name}" in ${res.path}${res.backup ? ` (backup ${res.backup})` : ''}`
266
- : `${client.label} — ${res.message}\n${res.snippet ?? ''}`);
366
+ ? `${client.label}: ${res.action} "${name}" in ${res.path}${res.backup ? ` (backup ${res.backup})` : ''}`
367
+ : `${client.label}: ${res.message}\n${res.snippet ?? ''}`);
267
368
  }
268
369
  else {
269
- blocks.push(`${client.label} — add to ${instr.path}:\n${instr.text}`);
370
+ blocks.push(`${client.label}: add to ${instr.path}\n${instr.text}`);
270
371
  }
271
372
  }
272
373
  if (targets.length === 0) {
@@ -278,7 +379,7 @@ export function registerAccountWizardTools(registry, server) {
278
379
  return textResult(`${blocks.join('\n\n')}\n\n${note}`);
279
380
  }
280
381
  catch (e) {
281
- return textResult(`account_write_config failed: ${e.message}`, true);
382
+ return errorResult('internal', `account_write_config failed: ${safeMessage(e)}`);
282
383
  }
283
384
  });
284
385
  }
@@ -3,7 +3,7 @@ import { coerceBoolean } from './_coerce.js';
3
3
  import { admin as adminClient } from '@googleapis/admin';
4
4
  import { accountAliasSchema } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
- import { handleGoogleApiError } from './_errors.js';
6
+ import { handleGoogleApiError, invalidParams } from './_errors.js';
7
7
  const accountEnum = accountAliasSchema.optional();
8
8
  // Admin SDK requires Workspace super-admin (or delegated admin) on the account — personal @gmail.com accounts 403 on every endpoint.
9
9
  export function registerAdminTools(server) {
@@ -98,7 +98,7 @@ export function registerAdminTools(server) {
98
98
  description: 'Get a single Workspace user by email or user ID',
99
99
  inputSchema: {
100
100
  account: accountEnum.describe('Google account alias (must be a Workspace admin)'),
101
- userKey: z.string().describe('User email or ID'),
101
+ userKey: z.string().min(1).describe('User email or ID'),
102
102
  projection: z.enum(['basic', 'custom', 'full']).optional(),
103
103
  },
104
104
  }, async ({ account, userKey, projection }) => {
@@ -121,7 +121,7 @@ export function registerAdminTools(server) {
121
121
  description: 'Update a Workspace user (PATCH semantics). Gated by write-control (a CUD tool).',
122
122
  inputSchema: {
123
123
  account: accountEnum.describe('Google account alias (must be a Workspace admin)'),
124
- userKey: z.string().describe('User email or ID'),
124
+ userKey: z.string().min(1).describe('User email or ID'),
125
125
  givenName: z.string().optional(),
126
126
  familyName: z.string().optional(),
127
127
  suspended: coerceBoolean.optional(),
@@ -150,7 +150,7 @@ export function registerAdminTools(server) {
150
150
  if (orgUnitPath !== undefined)
151
151
  requestBody.orgUnitPath = orgUnitPath;
152
152
  if (Object.keys(requestBody).length === 0) {
153
- return { content: [{ type: 'text', text: JSON.stringify({ error: 'No fields to update' }) }], isError: true };
153
+ return invalidParams(account, 'No fields to update: every optional field was omitted, so the request would have been a no-op.', 'Pass at least one of: givenName, familyName, suspended, password, changePasswordAtNextLogin, orgUnitPath.');
154
154
  }
155
155
  const res = await directory.users.patch({ userKey, requestBody });
156
156
  return {
@@ -197,7 +197,7 @@ export function registerAdminTools(server) {
197
197
  description: 'List members of a Workspace group',
198
198
  inputSchema: {
199
199
  account: accountEnum.describe('Google account alias (must be a Workspace admin)'),
200
- groupKey: z.string().describe('Group email or ID'),
200
+ groupKey: z.string().min(1).describe('Group email or ID'),
201
201
  roles: z.string().optional().describe('Comma-separated roles to include (OWNER, MANAGER, MEMBER)'),
202
202
  includeDerivedMembership: coerceBoolean.optional(),
203
203
  maxResults: z.number().min(1).max(200).optional(),
@@ -224,5 +224,6 @@ export function registerAdminTools(server) {
224
224
  });
225
225
  }
226
226
  function handleAdminError(error, account) {
227
- return handleGoogleApiError(error, account, "Admin tools require Workspace super-admin privileges AND the account must be listed in GOOGLE_ADMIN_ACCOUNTS (then re-authenticated). Personal Gmail accounts cannot use these endpoints.");
227
+ 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 });
228
229
  }
@@ -0,0 +1,18 @@
1
+ import type { ToolRegistry } from '../registry.js';
2
+ /** Accepts "213025502" or "properties/213025502"; rejects the identifiers
3
+ * people paste by mistake (G-… measurement IDs, UA-… properties) with a
4
+ * pointer to the right one. */
5
+ export declare function normalizeProperty(input: string): {
6
+ name: string;
7
+ } | {
8
+ hint: string;
9
+ };
10
+ /** GA4 report rows ({dimensionValues:[{value}], metricValues:[{value}]}) are
11
+ * verbose; merge each row into one {name: value} object (dimension and metric
12
+ * API names never collide). Shared by runReport and runRealtimeReport. */
13
+ export declare function shapeReport(data: any): Record<string, unknown>;
14
+ export declare function shapeAccountSummaries(data: any): Record<string, unknown>;
15
+ /** Full metadata descriptions run to paragraphs; the first ~160 chars carry
16
+ * the disambiguation the model needs without bloating a ~300-entry list. */
17
+ export declare function shapeMetadata(data: any): Record<string, unknown>;
18
+ export declare function registerAnalyticsTools(server: ToolRegistry): void;