mcp-google-multi 6.0.0-alpha.33 → 6.0.0-alpha.35

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 (74) hide show
  1. package/README.md +1 -5
  2. package/dist/accounts.d.ts +5 -0
  3. package/dist/accounts.js +12 -1
  4. package/dist/arg-normalize.d.ts +36 -0
  5. package/dist/arg-normalize.js +150 -7
  6. package/dist/arg-strict.d.ts +4 -2
  7. package/dist/arg-strict.js +27 -8
  8. package/dist/client.js +12 -4
  9. package/dist/discovery-client.js +31 -4
  10. package/dist/doctor.d.ts +6 -1
  11. package/dist/doctor.js +13 -3
  12. package/dist/executor.js +6 -1
  13. package/dist/fanout.js +43 -8
  14. package/dist/http-transport.d.ts +4 -1
  15. package/dist/http-transport.js +5 -2
  16. package/dist/index.js +23 -7
  17. package/dist/metrics-tap.d.ts +2 -1
  18. package/dist/metrics-tap.js +10 -10
  19. package/dist/registry.d.ts +20 -0
  20. package/dist/registry.js +59 -3
  21. package/dist/services.d.ts +8 -0
  22. package/dist/services.js +25 -0
  23. package/dist/tools/_errors.d.ts +31 -3
  24. package/dist/tools/_errors.js +278 -62
  25. package/dist/tools/account-wizard.d.ts +3 -0
  26. package/dist/tools/account-wizard.js +76 -28
  27. package/dist/tools/admin.js +7 -6
  28. package/dist/tools/analytics.js +6 -1
  29. package/dist/tools/calendar.d.ts +7 -0
  30. package/dist/tools/calendar.js +64 -15
  31. package/dist/tools/chat.js +7 -4
  32. package/dist/tools/contacts.js +57 -30
  33. package/dist/tools/docs.js +56 -54
  34. package/dist/tools/drive.js +99 -60
  35. package/dist/tools/forms.js +7 -7
  36. package/dist/tools/generated/admin.js +191 -191
  37. package/dist/tools/generated/analytics.js +58 -58
  38. package/dist/tools/generated/appsmarket.js +4 -4
  39. package/dist/tools/generated/calendar.js +28 -28
  40. package/dist/tools/generated/chat.js +44 -44
  41. package/dist/tools/generated/classroom.js +202 -202
  42. package/dist/tools/generated/cloudidentity.js +58 -58
  43. package/dist/tools/generated/cloudsearch.js +36 -36
  44. package/dist/tools/generated/contacts.js +7 -7
  45. package/dist/tools/generated/drive.js +47 -47
  46. package/dist/tools/generated/drivelabels.js +27 -27
  47. package/dist/tools/generated/forms.js +6 -6
  48. package/dist/tools/generated/gmail.js +97 -97
  49. package/dist/tools/generated/groupsmigration.js +1 -1
  50. package/dist/tools/generated/groupssettings.js +3 -3
  51. package/dist/tools/generated/keep.js +5 -5
  52. package/dist/tools/generated/licensing.js +23 -23
  53. package/dist/tools/generated/meet.js +13 -13
  54. package/dist/tools/generated/postmaster.js +3 -3
  55. package/dist/tools/generated/reseller.js +26 -26
  56. package/dist/tools/generated/script.js +16 -16
  57. package/dist/tools/generated/searchconsole.js +1 -1
  58. package/dist/tools/generated/sheets.js +8 -8
  59. package/dist/tools/generated/tasks.js +3 -3
  60. package/dist/tools/generated/vault.js +47 -47
  61. package/dist/tools/generated/workspaceevents.js +14 -14
  62. package/dist/tools/gmail.js +18 -13
  63. package/dist/tools/google-api.js +2 -2
  64. package/dist/tools/meet.js +5 -2
  65. package/dist/tools/searchconsole.js +10 -10
  66. package/dist/tools/sheets.js +36 -48
  67. package/dist/tools/slides.js +6 -6
  68. package/dist/tools/tasks.js +16 -16
  69. package/dist/trim.d.ts +21 -0
  70. package/dist/trim.js +47 -0
  71. package/dist/usage-metrics.js +5 -3
  72. package/dist/write-control.d.ts +14 -1
  73. package/dist/write-control.js +52 -7
  74. package/package.json +1 -1
package/README.md CHANGED
@@ -53,11 +53,7 @@ New to all this? It's written for someone who just installed Claude Code and has
53
53
 
54
54
  *On a server or from claude.ai?* Advanced path: [Remote / HTTP setup](./docs/http-setup.md). *Coming from v5?* [v6 migration guide](./MIGRATION-v6.md).
55
55
 
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) · [Migrating to v6](./MIGRATION-v6.md) · [Security policy](./SECURITY.md) · [Roadmap](https://github.com/bakissation/mcp-google-multi/milestones)
57
-
58
- ## Local usage metrics (off by default)
59
-
60
- The server can keep anonymous, **local-only** usage aggregates for its operator: tool names, error classes, latency buckets. Never arguments, payloads, message content, accounts, or identities of any kind, and **zero network egress ever** — the data cannot leave your machine unless you copy files yourself. It is off until you set `GOOGLE_USAGE_METRICS=on`; when on, the boot log and `doctor` say so and name the source. Read your own data with `mcp-google-multi metrics report`. Details, file format, and the honest threat model: [docs/usage-metrics.md](./docs/usage-metrics.md).
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)
61
57
 
62
58
  ## Maintainer & credits
63
59
 
@@ -30,6 +30,11 @@ export declare function isAccountSetStale(): boolean;
30
30
  * Snapshot from the initial load; enums widen only when the registry is
31
31
  * rebuilt after a mutation (account_add, later slice). */
32
32
  export declare const ACCOUNTS: string[];
33
+ /** One message for a mistyped alias, shared by every account schema. `aliases`
34
+ * is the SCHEMA's own snapshot, not the live registry: those are the values the
35
+ * enum actually accepts, and naming a runtime-added alias as valid while
36
+ * rejecting it would be a lie. */
37
+ export declare function unknownAliasMessage(aliases: readonly string[], selectors?: boolean): string;
33
38
  /**
34
39
  * The `account` param schema, empty-registry-safe. A `z.enum` requires at least
35
40
  * one value, so a fresh install (zero aliases) would throw at schema-build time
package/dist/accounts.js CHANGED
@@ -278,6 +278,15 @@ export function isAccountSetStale() {
278
278
  * Snapshot from the initial load; enums widen only when the registry is
279
279
  * rebuilt after a mutation (account_add, later slice). */
280
280
  export const ACCOUNTS = current.aliases;
281
+ /** One message for a mistyped alias, shared by every account schema. `aliases`
282
+ * is the SCHEMA's own snapshot, not the live registry: those are the values the
283
+ * enum actually accepts, and naming a runtime-added alias as valid while
284
+ * rejecting it would be a lie. */
285
+ export function unknownAliasMessage(aliases, selectors = false) {
286
+ const all = selectors ? '; "*" for all accounts' : '';
287
+ const csv = selectors && aliases.length > 1 ? `; or a CSV subset like "${aliases.slice(0, 2).join(',')}"` : '';
288
+ return `Unknown account alias. Valid: ${aliases.join(', ')}${all}${csv}. Run account_list if accounts changed since this server started.`;
289
+ }
281
290
  /**
282
291
  * The `account` param schema, empty-registry-safe. A `z.enum` requires at least
283
292
  * one value, so a fresh install (zero aliases) would throw at schema-build time
@@ -286,7 +295,9 @@ export const ACCOUNTS = current.aliases;
286
295
  * are no aliases: no alias exists to enumerate, the server refuses to boot empty
287
296
  * anyway, and dispatch validates the account against the live set. */
288
297
  export function accountAliasSchemaFor(aliases) {
289
- return aliases.length > 0 ? z.enum(aliases) : z.string();
298
+ return aliases.length > 0
299
+ ? z.enum(aliases, { error: () => unknownAliasMessage(aliases) })
300
+ : z.string();
290
301
  }
291
302
  /** Shared, load-time snapshot used by every tool's `account` field. */
292
303
  export const accountAliasSchema = accountAliasSchemaFor(ACCOUNTS);
@@ -1,5 +1,6 @@
1
1
  import type { Transport, JSONRPCMessage } from "@modelcontextprotocol/server";
2
2
  import { type SiblingSpelling, type UnknownArgMode } from './arg-strict.js';
3
+ export declare function isSchemaValidationText(text: string): boolean;
3
4
  export declare function argNormalizationEnabled(env?: NodeJS.ProcessEnv): boolean;
4
5
  /** Declared scalar kind per schema key; drives value coercion on RENAMED keys
5
6
  * only. Clients string-encode values for keys absent from the advertised
@@ -18,6 +19,10 @@ export interface StrictArgOptions {
18
19
  declaredFor: (tool: string) => readonly string[] | undefined;
19
20
  /** sibling spellings of a concept elsewhere in the same service */
20
21
  siblingsFor?: (tool: string, keys: string[]) => SiblingSpelling[];
22
+ /** Replacement text for the SDK's bare "Tool X not found". Still answered as
23
+ * a JSON-RPC error, which is what the spec prescribes for an unknown tool;
24
+ * only the message improves. */
25
+ unknownTool?: (tool: string) => string;
21
26
  onDrop?: (tool: string, resolvedKeys: string[]) => void;
22
27
  }
23
28
  export type ScreenOutcome = {
@@ -38,3 +43,34 @@ export declare function screenMessage(msg: JSONRPCMessage, opts: StrictArgOption
38
43
  * connect(); the interceptor lives in that setter, so the wrapper works
39
44
  * identically for stdio and (per-request, stateless) HTTP transports. */
40
45
  export declare function withArgNormalization(transport: Transport, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void, onRename?: (tool: string, renames: number) => void, strict?: StrictArgOptions): Transport;
46
+ export interface ValidationEnvelopeOptions {
47
+ /** Membership against the REGISTERED tool set: the name on the frame is
48
+ * ultimately client-supplied, and it must never enter an envelope unchecked. */
49
+ isKnownTool?: (name: string) => boolean;
50
+ /** The account the call WOULD have run as. The registry injects the default
51
+ * AFTER validation, so a validation failure never got that far. */
52
+ defaultAccount?: () => string | undefined;
53
+ }
54
+ interface PendingCall {
55
+ tool: string;
56
+ account?: string;
57
+ }
58
+ export declare function validationEnvelope(tool: string, issues: string, account: string | undefined): {
59
+ error: string;
60
+ message: string;
61
+ hint: string;
62
+ retriable: boolean;
63
+ account: string;
64
+ };
65
+ /** Floor for the OTHER bare prose the same SDK catch produces: an output-schema
66
+ * failure. Nothing is known about the cause, so it says exactly that. */
67
+ export declare function outputFailureEnvelope(tool: string, text: string, account: string | undefined): {
68
+ error: string;
69
+ message: string;
70
+ hint: string;
71
+ retriable: boolean;
72
+ account: string;
73
+ };
74
+ export declare function rewriteValidationFrame(message: JSONRPCMessage, pending: Map<string | number, PendingCall>, opts: ValidationEnvelopeOptions): JSONRPCMessage;
75
+ export declare function withValidationEnvelope(transport: Transport, opts?: ValidationEnvelopeOptions): Transport;
76
+ export {};
@@ -1,4 +1,21 @@
1
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 "{".
8
+ const SCHEMA_VALIDATION_TEXT = /^(MCP error -32602: )?Input validation error:/;
9
+ export function isSchemaValidationText(text) {
10
+ return SCHEMA_VALIDATION_TEXT.test(text);
11
+ }
12
+ /** Capture form of the v2 text. The tool-name class excludes ':' and ' ', so
13
+ * the first ": " after the name is unambiguously the delimiter. */
14
+ 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). */
18
+ const SDK_OUTPUT_PROSE = /^(?:MCP error -32602: )?Output validation error: /;
2
19
  // Wire-level tools/call argument normalization. Clients (LLMs) recurringly
3
20
  // snake_case a camelCase parameter (thread_id for threadId) and burn a retry
4
21
  // on the -32602. A schema-level fix is off the table: the SDK advertises an
@@ -77,9 +94,23 @@ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}
77
94
  return { action: 'forward', msg };
78
95
  const tool = m.params.name;
79
96
  const declared = opts.declaredFor(tool);
80
- // Unregistered tool: leave it to the SDK's own "not found".
81
- if (!declared)
82
- return { action: 'forward', msg };
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.
102
+ if (!declared) {
103
+ if (!opts.unknownTool || m.id === undefined)
104
+ return { action: 'forward', msg };
105
+ return {
106
+ action: 'reject',
107
+ response: {
108
+ jsonrpc: '2.0',
109
+ id: m.id,
110
+ error: { code: -32602, message: opts.unknownTool(tool) },
111
+ },
112
+ };
113
+ }
83
114
  const screened = screenArguments(tool, args, declared);
84
115
  if (screened.unknown.length === 0 && screened.redundant.length === 0)
85
116
  return { action: 'forward', msg };
@@ -134,15 +165,18 @@ export function withArgNormalization(transport, shapeFor, log, onRename, strict)
134
165
  const outcome = screenMessage(normalized, strict, log);
135
166
  if (outcome.action === 'forward')
136
167
  return handler(outcome.msg, extra);
168
+ // 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.
172
+ const fallback = () => handler(normalized, extra);
137
173
  try {
138
174
  // Answer on the INNER transport so the metrics tap still sees the
139
175
  // frame and clears its pending id.
140
- void transport.send(outcome.response);
176
+ void Promise.resolve(transport.send(outcome.response)).catch(fallback);
141
177
  }
142
178
  catch {
143
- // Last-resort: dispatch as before rather than hang the caller.
144
- // The stderr line and the counter already fired above.
145
- handler(normalized, extra);
179
+ fallback();
146
180
  }
147
181
  }
148
182
  : undefined;
@@ -169,3 +203,112 @@ export function withArgNormalization(transport, shapeFor, log, onRename, strict)
169
203
  }
170
204
  return wrapper;
171
205
  }
206
+ const MAX_ACCOUNT_CHARS = 64;
207
+ const MAX_ISSUE_CHARS = 800;
208
+ const MAX_PROSE_CHARS = 500;
209
+ const ENVELOPE_PENDING_CAP = 1_000;
210
+ export function validationEnvelope(tool, issues, account) {
211
+ return {
212
+ error: 'validation_error',
213
+ message: `${tool} arguments failed validation: ${sliceClean(issues, MAX_ISSUE_CHARS)}`,
214
+ hint: `Fix the argument named in the message and call again. This tool's inputSchema in tools/list lists every accepted argument, its type and its allowed values. Nothing was sent to Google.`,
215
+ retriable: false,
216
+ account: account ?? '',
217
+ };
218
+ }
219
+ /** Floor for the OTHER bare prose the same SDK catch produces: an output-schema
220
+ * failure. Nothing is known about the cause, so it says exactly that. */
221
+ export function outputFailureEnvelope(tool, text, account) {
222
+ return {
223
+ error: 'internal',
224
+ message: `${tool} returned a result the protocol rejected: ${sliceClean(text, MAX_PROSE_CHARS)}`,
225
+ hint: 'A server-side failure, not a rejected argument. Retry once; if it repeats, the message above is the signal to report.',
226
+ retriable: false,
227
+ account: account ?? '',
228
+ };
229
+ }
230
+ export function rewriteValidationFrame(message, pending, opts) {
231
+ 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.
235
+ if (m.id === undefined || m.method !== undefined)
236
+ return message;
237
+ const call = pending.get(m.id);
238
+ pending.delete(m.id);
239
+ if (m.result?.isError !== true)
240
+ return message;
241
+ const content = m.result.content;
242
+ if (!content || content.length !== 1 || content[0]?.type !== 'text')
243
+ return message;
244
+ const text = content[0].text;
245
+ if (typeof text !== 'string' || text.startsWith('{'))
246
+ return message;
247
+ const parsed = isSchemaValidationText(text) ? VALIDATION_PROSE.exec(text) : null;
248
+ // Anything the SDK did not author is a handler's own answer. Leave it alone.
249
+ if (!parsed && !SDK_OUTPUT_PROSE.test(text))
250
+ return message;
251
+ const tool = parsed?.[1] ?? call?.tool;
252
+ if (!tool || (opts.isKnownTool && !opts.isKnownTool(tool)))
253
+ return message;
254
+ const account = call?.account ?? opts.defaultAccount?.();
255
+ const envelope = parsed
256
+ ? validationEnvelope(tool, parsed[2], account)
257
+ : outputFailureEnvelope(tool, text, account);
258
+ return {
259
+ ...message,
260
+ result: { ...m.result, content: [{ type: 'text', text: JSON.stringify(envelope) }] },
261
+ };
262
+ }
263
+ export function withValidationEnvelope(transport, opts = {}) {
264
+ const pending = new Map();
265
+ const wrapper = {
266
+ start: () => transport.start(),
267
+ send: (message, options) => {
268
+ let out = message;
269
+ try {
270
+ out = rewriteValidationFrame(message, pending, opts);
271
+ }
272
+ catch { /* the seam may never break the wire */ }
273
+ return transport.send(out, options);
274
+ },
275
+ close: () => transport.close(),
276
+ };
277
+ Object.defineProperty(wrapper, 'onmessage', {
278
+ get: () => transport.onmessage,
279
+ set: (handler) => {
280
+ transport.onmessage = handler
281
+ ? (message, extra) => {
282
+ try {
283
+ const m = message;
284
+ if (m.method === 'tools/call' && m.id !== undefined && typeof m.params?.name === 'string') {
285
+ if (pending.size >= ENVELOPE_PENDING_CAP)
286
+ pending.clear();
287
+ const args = m.params.arguments;
288
+ const raw = args && typeof args === 'object' ? args.account : undefined;
289
+ pending.set(m.id, {
290
+ tool: m.params.name,
291
+ account: typeof raw === 'string' ? sliceClean(raw, MAX_ACCOUNT_CHARS) : undefined,
292
+ });
293
+ }
294
+ }
295
+ catch { /* never break dispatch */ }
296
+ handler(message, extra);
297
+ }
298
+ : undefined;
299
+ },
300
+ });
301
+ for (const prop of ['onclose', 'onerror']) {
302
+ Object.defineProperty(wrapper, prop, {
303
+ get: () => transport[prop],
304
+ set: (v) => {
305
+ transport[prop] = v;
306
+ },
307
+ });
308
+ }
309
+ Object.defineProperty(wrapper, 'sessionId', { get: () => transport.sessionId });
310
+ if (transport.setProtocolVersion) {
311
+ wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
312
+ }
313
+ return wrapper;
314
+ }
@@ -1,7 +1,9 @@
1
1
  export type UnknownArgMode = 'reject' | 'warn' | 'off';
2
2
  /**
3
- * `GOOGLE_ARG_UNKNOWN`: reject | warn | off. Fail-open to `warn` on a bad
4
- * value, because the safe state here is the one that changes no behavior.
3
+ * `GOOGLE_ARG_UNKNOWN`: reject | warn | off. A typo in the VALUE still falls
4
+ * back to `warn`, not to the default: an operator who misspells the setting
5
+ * has said nothing about which behavior they want, and guessing the strict
6
+ * one would turn a config typo into failing tool calls.
5
7
  */
6
8
  export declare function unknownArgMode(env?: NodeJS.ProcessEnv): UnknownArgMode;
7
9
  /** Tools that legitimately accept open-ended top-level keys. Empty at 6.0.0:
@@ -11,8 +11,10 @@
11
11
  // value to Google on a guess, which is strictly worse than refusing.
12
12
  import { editDistance } from './scope-catalog.js';
13
13
  /**
14
- * `GOOGLE_ARG_UNKNOWN`: reject | warn | off. Fail-open to `warn` on a bad
15
- * value, because the safe state here is the one that changes no behavior.
14
+ * `GOOGLE_ARG_UNKNOWN`: reject | warn | off. A typo in the VALUE still falls
15
+ * back to `warn`, not to the default: an operator who misspells the setting
16
+ * has said nothing about which behavior they want, and guessing the strict
17
+ * one would turn a config typo into failing tool calls.
16
18
  */
17
19
  export function unknownArgMode(env = process.env) {
18
20
  const raw = (env.GOOGLE_ARG_UNKNOWN ?? '').trim().toLowerCase();
@@ -20,21 +22,38 @@ export function unknownArgMode(env = process.env) {
20
22
  return DEFAULT_MODE;
21
23
  if (raw === 'reject' || raw === 'warn' || raw === 'off')
22
24
  return raw;
23
- process.stderr.write(`GOOGLE_ARG_UNKNOWN="${raw}" is not valid (reject | warn | off); using ${DEFAULT_MODE}\n`);
24
- return DEFAULT_MODE;
25
+ process.stderr.write(`GOOGLE_ARG_UNKNOWN="${raw}" is not valid (reject | warn | off); using warn\n`);
26
+ return 'warn';
25
27
  }
26
- // Staged rollout: `warn` ships first so the change is pure observability, and
27
- // the flip to `reject` is its own reviewable change.
28
- const DEFAULT_MODE = 'warn';
28
+ // The staged rollout is over: `warn` shipped first as pure observability, and
29
+ // this is the flip it was staging for. An undeclared argument is dropped by
30
+ // zod before the handler runs, so `warn` means the CLIENT sees a confident
31
+ // success for a call the server did not perform: drive_list with a misspelled
32
+ // folder key returned the My Drive root, byte-identical to no argument at all.
33
+ // A wrong answer that reads as right is the failure mode agents recover from
34
+ // worst, so the default refuses the call and says what it probably meant.
35
+ const DEFAULT_MODE = 'reject';
29
36
  /** Tools that legitimately accept open-ended top-level keys. Empty at 6.0.0:
30
37
  * the escape hatch is NOT one, because its open-endedness lives in the VALUES
31
38
  * of queryParams/body, never in its six fixed top-level keys. */
32
39
  export const STRICT_EXEMPT_TOOLS = new Set();
40
+ /** Keys a CLIENT adds on its own, not keys the model chose. `random_string`
41
+ * is the long-standing probe some clients send to a tool they read as taking
42
+ * no arguments; the others are call-plumbing that bridges and proxies have
43
+ * been observed to fold into `arguments` instead of `params._meta`, where the
44
+ * spec puts them. Rejecting these would fail a call the model got right.
45
+ * Measured against the full 978-tool surface: none is a declared key. */
46
+ const CLIENT_ARTIFACT_KEYS = new Set([
47
+ 'random_string',
48
+ 'toolCallId',
49
+ 'tool_call_id',
50
+ 'tool_call_description',
51
+ ]);
33
52
  /** Metadata a client may legitimately attach. Measured against all registered
34
53
  * tools: no declared key starts with `_` or contains `/`, so neither rule can
35
54
  * shadow a real parameter. */
36
55
  export function isExemptKey(key) {
37
- return key.startsWith('_') || key.includes('/');
56
+ return key.startsWith('_') || key.includes('/') || CLIENT_ARTIFACT_KEYS.has(key);
38
57
  }
39
58
  /** Generic words that must never be offered as a suggestion on containment
40
59
  * alone; they match far too much to be useful. */
package/dist/client.js CHANGED
@@ -2,24 +2,32 @@ import { OAuth2Client } from 'googleapis-common';
2
2
  import { getAccountSet, refreshAccountSetIfStale } from './accounts.js';
3
3
  import { readToken, updateToken } from './token-store.js';
4
4
  import { reauthHint } from './reauth-hint.js';
5
+ /** A local precondition failure, carrying a code the error mapper classifies
6
+ * on. Without it these land on the generic floor and read as Google errors. */
7
+ function tagged(message, code) {
8
+ return Object.assign(new Error(message), { code });
9
+ }
5
10
  export async function getClient(account) {
6
11
  // BR-7: lazy cross-process reload — one stat per dispatch, no watcher;
7
12
  // reload failures keep the last-good registry, never kill the server.
8
13
  refreshAccountSetIfStale();
9
14
  const config = getAccountSet().configs[account];
10
15
  if (!config) {
11
- throw new Error(`Unknown account "${account}". Valid aliases: ${getAccountSet().aliases.join(', ')}`);
16
+ // Tagged so mapGoogleError can classify it. Untagged, a caller's typo in
17
+ // an alias came back as `upstream_error` with "Unclassified error", i.e.
18
+ // the server blaming Google for a local argument mistake.
19
+ throw tagged(`Unknown account "${account}". Valid aliases: ${getAccountSet().aliases.join(', ')}`, 'E_UNKNOWN_ACCOUNT');
12
20
  }
13
21
  if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
14
- throw new Error('GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set. ' +
15
- 'Check that .env exists in the project root or pass them as env vars.');
22
+ throw tagged('GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set. ' +
23
+ 'Check that .env exists in the project root or pass them as env vars.', 'E_NO_OAUTH_CLIENT');
16
24
  }
17
25
  // Redirect URI is unused on the refresh-token grant; consent flows bind an
18
26
  // ephemeral loopback port at auth time (oauth-consent.ts).
19
27
  const oauth2Client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost/oauth2callback');
20
28
  const tokenData = readToken(account);
21
29
  if (!tokenData) {
22
- throw new Error(`No token found for account "${account}" (${config.email}). ${reauthHint(account)}`);
30
+ throw tagged(`No token found for account "${account}" (${config.email}). ${reauthHint(account)}`, 'E_NO_TOKEN');
23
31
  }
24
32
  oauth2Client.setCredentials(tokenData);
25
33
  oauth2Client.on('tokens', (tokens) => {
@@ -198,11 +198,22 @@ export function clearDiscoveryMemoryCache() {
198
198
  }
199
199
  // GA4-style report execution (runReport, batchRunPivotReports, runAccessReport)
200
200
  // and check* predicates are POSTs purely for the request-body size — reads.
201
- const POST_READ_VERB = /^(get|list|search|query|lookup|count|batchGet|generateIds|export|download|inspect|check|(batch)?run\w*report)/i;
202
- const POST_UPDATE_VERB = /^(untrash|undelete|restore|modify|move|set|sort|merge|unmerge|replace|resize|publish|resolve|update|patch|write|format)/i;
201
+ const POST_READ_VERB = /^(get|list|search|query|lookup|count|batchGet|generateIds|export|download|inspect|check|suggest|(batch)?run\w*report)/i;
202
+ // `cancelWipe` must be matched here, BEFORE `cancel` reaches the delete list:
203
+ // it calls off a pending wipe, which is the opposite of one. Update is tested
204
+ // before delete for exactly that reason.
205
+ // State transitions on something that already exists: reversible, and none of
206
+ // them creates anything, so `create` was both wrong and the most permissive
207
+ // class available. `(batch)?` mirrors the delete list, without which every
208
+ // `batchUpdate*` POST fell through to `create`.
209
+ const POST_UPDATE_VERB = /^(cancelWipe|(batch)?(untrash|undelete|restore|modify|move|set|sort|merge|unmerge|replace|resize|publish|resolve|update|patch|write|format|change|close|reopen|enable|disable|hide|unhide|accept|approve|decline|reassign|reactivate|renew|mark|turn|suspend|activate|make|return|reclaim|complete))/i;
203
210
  // archive sits with the deletes: in GA4 archiving a custom dimension/metric is
204
211
  // permanent, so the most restrictive write class is the safe classification.
205
- const POST_DELETE_VERB = /^(batch)?(delete|remove|trash|clear|empty|obliterate|purge|revoke|wipeout|archive)/i;
212
+ // So do the teardown verbs: `stop` and `cancel` tear down a push channel or a
213
+ // long-running operation, `wipe`/`invalidate`/`signOut` destroy device data,
214
+ // codes and sessions, `reset` discards a configuration, and `end` terminates a
215
+ // live conference. Each removes something that existed.
216
+ const POST_DELETE_VERB = /^(batch)?(delete|remove|trash|clear|empty|obliterate|purge|revoke|wipeout|archive|wipe|invalidate|signOut|stop|cancel|unregister|unreserve|reset|end)/i;
206
217
  export function cudFromMethod(method) {
207
218
  switch (method.httpMethod) {
208
219
  case 'GET':
@@ -233,7 +244,23 @@ export function expandPath(template, pathParams) {
233
244
  if (value === undefined) {
234
245
  throw new Error(`Missing required path parameter "${name}"`);
235
246
  }
236
- return plus ? String(value).split('/').map(encodeURIComponent).join('/') : encodeURIComponent(String(value));
247
+ // A blank segment collapses the path and Google routes the resulting
248
+ // trailing-slash URL to the COLLECTION: `sites.get` with siteUrl:"" came
249
+ // back as the whole site LIST, reported as success. Dot segments resolve
250
+ // at URL-parse time, so `../v1/contactGroups` retargets the call at a
251
+ // different endpoint and returns ITS response as success. Both are
252
+ // silent-wrong, so refuse locally rather than send a different request.
253
+ // `{+param}` keeps its slashes, so every segment has to be checked.
254
+ const segments = plus ? String(value).split('/') : [String(value)];
255
+ for (const segment of segments) {
256
+ if (segment.trim() === '') {
257
+ throw new Error(`Path parameter "${name}" is empty. An empty value addresses the collection, not one resource, so nothing was sent to Google.`);
258
+ }
259
+ if (segment === '.' || segment === '..') {
260
+ throw new Error(`Path parameter "${name}" contains a "${segment}" path segment, which would retarget the request at a different endpoint. Nothing was sent to Google.`);
261
+ }
262
+ }
263
+ return segments.map(encodeURIComponent).join('/');
237
264
  });
238
265
  }
239
266
  export function searchMethods(index, query, limit = 10) {
package/dist/doctor.d.ts CHANGED
@@ -67,7 +67,12 @@ export declare function renderReport(report: DiagnosticsReport, version: string)
67
67
  /** Exit non-zero when any section FAILs; with strict, WARN counts too. */
68
68
  export declare function exitCodeFor(report: DiagnosticsReport, strict: boolean): number;
69
69
  /** Agent-callable structured health report (read-only). Mirrors `doctor`'s
70
- * engine; NOT alwaysLoad (the agent asks for it when diagnosing). */
70
+ * engine. Registered as a META tool, like `account_list`: it introspects this
71
+ * server rather than Google data, and as a normal tool it became a service of
72
+ * its own with no `{service}_discover` to reveal it (discover tools are built
73
+ * from already-registered services, and this runs after that), so it was
74
+ * advertised only once something else expanded the surface. The README sends
75
+ * people here when they are stuck, so it has to be findable. */
71
76
  export declare function registerDiagnoseTool(registry: ToolRegistry): void;
72
77
  export interface ResetOptions {
73
78
  account?: string;
package/dist/doctor.js CHANGED
@@ -9,6 +9,7 @@ import { configDir, loadConfigFile } from './config-file.js';
9
9
  import { envValueSource } from './env-load.js';
10
10
  import { describeMetricsDir, resolveUsageMetrics, sourceLabel } from './usage-metrics.js';
11
11
  import { probeApiEnablement } from './api-probe.js';
12
+ import { safeMessage, stringifyEnvelope } from './tools/_errors.js';
12
13
  import { resolveHttpConfig, HttpConfigError } from './http-config.js';
13
14
  import { parseOwnerEmails } from './http-transport.js';
14
15
  const MIN_NODE_MAJOR = 22;
@@ -356,9 +357,14 @@ export function exitCodeFor(report, strict) {
356
357
  return 0;
357
358
  }
358
359
  /** Agent-callable structured health report (read-only). Mirrors `doctor`'s
359
- * engine; NOT alwaysLoad (the agent asks for it when diagnosing). */
360
+ * engine. Registered as a META tool, like `account_list`: it introspects this
361
+ * server rather than Google data, and as a normal tool it became a service of
362
+ * its own with no `{service}_discover` to reveal it (discover tools are built
363
+ * from already-registered services, and this runs after that), so it was
364
+ * advertised only once something else expanded the surface. The README sends
365
+ * people here when they are stuck, so it has to be findable. */
360
366
  export function registerDiagnoseTool(registry) {
361
- registry.registerTool('diagnose', {
367
+ registry.registerMeta('diagnose', {
362
368
  annotations: { readOnlyHint: true, openWorldHint: true },
363
369
  description: 'Health report for this server: runtime, config, keys, per-account token status, scope grants, and API enablement. Read-only; returns copy-pasteable fixes for anything wrong. Call this to diagnose auth/config failures.',
364
370
  inputSchema: {},
@@ -368,7 +374,11 @@ export function registerDiagnoseTool(registry) {
368
374
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
369
375
  }
370
376
  catch (e) {
371
- return { content: [{ type: 'text', text: JSON.stringify({ error: 'diagnose_failed', message: e?.message ?? String(e) }) }], isError: true };
377
+ return { content: [{ type: 'text', text: stringifyEnvelope({
378
+ error: 'diagnose_failed',
379
+ message: safeMessage(e),
380
+ retriable: false,
381
+ }) }], isError: true };
372
382
  }
373
383
  });
374
384
  }
package/dist/executor.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { getClient } from './client.js';
2
+ import { sliceEncoded } from './trim.js';
2
3
  import { expandPath, isGoogleApiUrl } from './discovery-client.js';
3
4
  import { handleGoogleApiError } from './tools/_errors.js';
4
5
  import { scopeHintForMethod } from './scope-observability.js';
@@ -88,10 +89,14 @@ export async function executeApiMethod(method, args, deps = {}) {
88
89
  });
89
90
  const text = JSON.stringify(res.data ?? null);
90
91
  if (text.length > MAX_RESPONSE_CHARS) {
92
+ // Budget the ENCODED head: jsonResult re-stringifies it, so every quote
93
+ // and backslash inside doubles. Slicing raw characters made the emitted
94
+ // result exceed the cap this tool advertises, by up to 15 percent on a
95
+ // quote-dense listing.
91
96
  return jsonResult({
92
97
  truncated: true,
93
98
  totalChars: text.length,
94
- head: text.slice(0, MAX_RESPONSE_CHARS),
99
+ head: sliceEncoded(text, MAX_RESPONSE_CHARS),
95
100
  hint: 'Narrow the request (fields mask, pageSize) to get complete JSON.',
96
101
  });
97
102
  }
package/dist/fanout.js CHANGED
@@ -1,21 +1,33 @@
1
1
  import { z } from 'zod';
2
- import { ACCOUNTS, getAccountSet } from './accounts.js';
2
+ import { ACCOUNTS, getAccountSet, unknownAliasMessage } from './accounts.js';
3
3
  export const CSV_RE = /^[a-zA-Z0-9_-]+(\s*,\s*[a-zA-Z0-9_-]+)+$/;
4
4
  const FANOUT_CONCURRENCY = 5;
5
5
  export function fanoutAccountField(description) {
6
6
  const csvExample = ACCOUNTS.length > 1 ? `; or a CSV subset like "${ACCOUNTS.slice(0, 2).join(',')}"` : '';
7
+ // The union's OWN error only fires when every branch aborts at the type
8
+ // check (a non-string). For a string, zod surfaces the single non-aborted
9
+ // branch verbatim, which is the CSV regex, so a mistyped alias used to read
10
+ // as "Invalid string: must match pattern /^[a-zA-Z0-9_-]+(,...)/" and never
11
+ // named an alias. Hence the same message on BOTH.
12
+ const bad = () => unknownAliasMessage(ACCOUNTS, true);
7
13
  return z
8
14
  // '*' first so the tuple is statically non-empty even when ACCOUNTS is empty
9
15
  // (a fresh install): z.enum requires [string, ...string[]].
10
- .union([z.enum(['*', ...ACCOUNTS]), z.string().regex(CSV_RE)])
16
+ .union([z.enum(['*', ...ACCOUNTS]), z.string().regex(CSV_RE, { error: bad })], { error: bad })
11
17
  .optional()
12
18
  .describe(`${description}; "*" = all accounts${csvExample}; omit for the default account`);
13
19
  }
14
20
  export function parseAccountSelector(value, accounts = getAccountSet().aliases) {
15
21
  if (value === '*')
16
22
  return { ok: true, fanout: true, aliases: [...accounts] };
17
- if (!value.includes(','))
18
- return { ok: true, fanout: false, aliases: [value] };
23
+ // A single alias used to skip validation entirely and reach getClient, whose
24
+ // throw landed on the mapper's floor as an unclassified upstream_error. Same
25
+ // check and same envelope wherever the alias was typed.
26
+ if (!value.includes(',')) {
27
+ return accounts.includes(value)
28
+ ? { ok: true, fanout: false, aliases: [value] }
29
+ : { ok: false, invalid: [value] };
30
+ }
19
31
  const seen = new Set();
20
32
  const aliases = [];
21
33
  const invalid = [];
@@ -30,7 +42,9 @@ export function parseAccountSelector(value, accounts = getAccountSet().aliases)
30
42
  }
31
43
  if (invalid.length > 0)
32
44
  return { ok: false, invalid };
33
- return { ok: true, fanout: aliases.length > 1, aliases };
45
+ // Keyed on the CSV FORM, not the deduped count: "a,a" asked for the merged
46
+ // multi-account envelope and used to get a bare single-account payload.
47
+ return { ok: true, fanout: true, aliases };
34
48
  }
35
49
  export function invalidAccountsResult(invalid, accounts = getAccountSet().aliases) {
36
50
  return {
@@ -40,8 +54,9 @@ export function invalidAccountsResult(invalid, accounts = getAccountSet().aliase
40
54
  text: JSON.stringify({
41
55
  error: 'validation_error',
42
56
  message: `Unknown account alias(es): ${invalid.join(', ')}.`,
43
- hint: `Valid aliases: ${accounts.join(', ')}; or "*" for all accounts.`,
57
+ hint: unknownAliasMessage(accounts, true),
44
58
  retriable: false,
59
+ account: invalid.join(','),
45
60
  }),
46
61
  },
47
62
  ],
@@ -82,7 +97,27 @@ export async function runFanout(handler, args, aliases) {
82
97
  };
83
98
  await Promise.all(Array.from({ length: Math.min(FANOUT_CONCURRENCY, aliases.length) }, () => worker()));
84
99
  const failed = results.filter((r) => !r.ok).length;
100
+ if (failed === results.length && results.length > 0) {
101
+ // Every account failed, so this IS an error result, and used to carry
102
+ // `isError` with no top-level error, hint or retriable: the agent saw a
103
+ // successful-looking object flagged as an error, and metrics bucketed it
104
+ // as `other`. Hoist a real envelope, keeping the per-account detail.
105
+ const slugs = [...new Set(results.map((r) => r.error?.error ?? 'internal'))];
106
+ const hints = [...new Set(results.map((r) => r.error?.hint).filter(Boolean))];
107
+ const retriable = results.every((r) => r.error?.retriable === true);
108
+ return {
109
+ content: [{ type: 'text', text: JSON.stringify({
110
+ error: 'fanout_failed',
111
+ message: `All ${results.length} accounts failed: ${slugs.join(', ')}. Per-account detail is in results.`,
112
+ hint: hints.length === 1 ? hints[0] : 'Each entry in results carries its own error and hint; they differ per account.',
113
+ retriable,
114
+ account: aliases.join(','),
115
+ results,
116
+ partial: true,
117
+ }) }],
118
+ isError: true,
119
+ };
120
+ }
85
121
  const body = { results, partial: failed > 0 };
86
- const out = { content: [{ type: 'text', text: JSON.stringify(body) }] };
87
- return failed === results.length ? { ...out, isError: true } : out;
122
+ return { content: [{ type: 'text', text: JSON.stringify(body) }] };
88
123
  }
@@ -1,7 +1,7 @@
1
1
  import type { McpServer, Transport } from "@modelcontextprotocol/server";
2
2
  import { type IncomingMessage, type ServerResponse } from 'node:http';
3
3
  import type { HttpConfig } from './http-config.js';
4
- import { type ArgShape, type StrictArgOptions } from './arg-normalize.js';
4
+ import { type ArgShape, type StrictArgOptions, type ValidationEnvelopeOptions } from './arg-normalize.js';
5
5
  export type AuthOutcome = {
6
6
  ok: true;
7
7
  } | {
@@ -33,6 +33,9 @@ export interface HttpHostOptions {
33
33
  argShapeFor?: (tool: string) => ArgShape | undefined;
34
34
  /** Usage-metrics transport tap (metrics-tap.ts); absent = off. */
35
35
  metricsTap?: (t: Transport) => Transport;
36
+ /** Wired identically on both transports so a schema rejection reads the same
37
+ * over stdio and over HTTP. */
38
+ validationEnvelope?: ValidationEnvelopeOptions;
36
39
  /** Usage-metrics argfix observer, forwarded into arg normalization. */
37
40
  onArgRename?: (tool: string, renames: number) => void;
38
41
  /** Unknown-argument screening (arg-strict.ts); absent = off. */