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
package/dist/registry.js CHANGED
@@ -1,10 +1,14 @@
1
- import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
2
1
  import { z } from 'zod';
3
2
  import { isAllowed, writeDisabledResult, IRREVERSIBLE_TOOLS } from './write-control.js';
4
3
  import { getAccountSet, refreshAccountSetIfStale } from './accounts.js';
5
4
  import { compactResult, trimEnabled } from './trim.js';
6
5
  import { fanoutAccountField, invalidAccountsResult, parseAccountSelector, runFanout } from './fanout.js';
7
6
  import { MAX_RESPONSE_CHARS } from './executor.js';
7
+ import { suggestKeys } from './arg-strict.js';
8
+ import { classifyScope } from './scope-observability.js';
9
+ /** Nothing granted, no profile: asks the BUNDLE CATALOG whether a scope is
10
+ * reachable at all, independently of any account. */
11
+ const EMPTY_SCOPES = new Set();
8
12
  // Client-side result budget advertised for tools that do not declare their own
9
13
  // (fat readers do; see trim.ts). ~50k chars stays well inside a default client
10
14
  // context limit while leaving room for real list payloads.
@@ -38,6 +42,11 @@ const SERVICE_OVERRIDES = {
38
42
  };
39
43
  // read tools that write local files — same savePath fanned across accounts would clobber
40
44
  const FANOUT_EXCLUDE = new Set(['gmail_download_attachment', 'drive_download', 'drive_export']);
45
+ /** Account-management tools: the `account` they take is the SUBJECT of the
46
+ * operation, not the identity it runs as, so injecting the configured default
47
+ * would silently retarget them. `account_add` would adopt the default as the
48
+ * new alias, and `account_reauth` would re-authenticate the wrong account. */
49
+ const DEFAULT_ACCOUNT_EXCLUDE = new Set(['account_add', 'account_reauth']);
41
50
  /** Unwrap optional/default/nullable to the declared scalar kind (zod 4 defs). */
42
51
  function scalarKindOf(field) {
43
52
  let cur = field;
@@ -81,12 +90,16 @@ export function inferCud(name) {
81
90
  }
82
91
  export class ToolRegistry {
83
92
  server;
93
+ metrics;
84
94
  tools = [];
85
95
  policy;
86
96
  registerTool;
87
97
  revealed = new Set();
88
98
  jsonSchemaCache = new Map();
89
99
  argShapeCache = new Map();
100
+ /** Memoized isUngrantable verdict per tool: the answer depends on the frozen
101
+ * bundle catalog only, so it never changes within a process. */
102
+ ungrantable = new Map();
90
103
  compactOutput = trimEnabled();
91
104
  registeringMeta = false;
92
105
  /** Configured visibility mode (GOOGLE_DISCOVERY); default lazy = v5 exact. */
@@ -94,8 +107,9 @@ export class ToolRegistry {
94
107
  /** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
95
108
  * lazy surface to curated without touching the configured mode. */
96
109
  expanded = false;
97
- constructor(server, policy, mode = resolveDiscoveryMode()) {
110
+ constructor(server, policy, mode = resolveDiscoveryMode(), metrics = null) {
98
111
  this.server = server;
112
+ this.metrics = metrics;
99
113
  this.policy = policy;
100
114
  this.mode = mode;
101
115
  this.registerTool = ((name, config, handler) => {
@@ -125,7 +139,7 @@ export class ToolRegistry {
125
139
  // never fan out meta tools: google_api_call infers cud=read but executes writes
126
140
  let inputShape = config.inputSchema ?? {};
127
141
  let baseHandler = handler;
128
- const hasAccountField = 'account' in inputShape;
142
+ const hasAccountField = 'account' in inputShape && !DEFAULT_ACCOUNT_EXCLUDE.has(name);
129
143
  if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) && isAccountEnum(inputShape.account)) {
130
144
  const description = inputShape.account.description ?? 'Google account alias';
131
145
  inputShape = { ...inputShape, account: fanoutAccountField(description) };
@@ -154,9 +168,11 @@ export class ToolRegistry {
154
168
  });
155
169
  const guarded = cud === 'read'
156
170
  ? baseHandler
157
- : (...args) => isAllowed({ name, service, cud }, policy)
171
+ : (...args) => isAllowed({ name, service, cud, scopes: config.requiredScopes }, policy)
158
172
  ? baseHandler(...args)
159
- : writeDisabledResult({ name, service, cud }, policy);
173
+ : writeDisabledResult({ name, service, cud, scopes: config.requiredScopes }, policy,
174
+ // Concrete by here: default-account injection wraps this.
175
+ args[0]?.account);
160
176
  // A2: the ONE default-account injection site — outside the CUD gate and
161
177
  // the fan-out parse so both observe a concrete alias; NOT meta-skipped
162
178
  // (that is what covers google_api_call with zero bespoke code). Explicit
@@ -194,8 +210,21 @@ export class ToolRegistry {
194
210
  const finalHandler = this.compactOutput
195
211
  ? async (...args) => compactResult(await withDefault(...args))
196
212
  : withDefault;
213
+ // Usage metrics wrap OUTERMOST and only when enabled: off means no
214
+ // wrapper exists and the chain is byte-identical to the pre-metrics
215
+ // chain. Fan-out width is derived here (bucketed in the module) so the
216
+ // metrics module never imports fanout.
217
+ const instrumented = this.metrics
218
+ ? this.metrics.wrap({ name, service, meta: this.registeringMeta, generated: config.cud !== undefined }, finalHandler, (a) => {
219
+ const v = a?.account;
220
+ if (typeof v !== 'string' || (v !== '*' && !v.includes(',')))
221
+ return 1;
222
+ const sel = parseAccountSelector(v);
223
+ return sel.ok ? sel.aliases.length : 1;
224
+ })
225
+ : finalHandler;
197
226
  const { cud: _cud, ...sdkConfig } = config;
198
- return server.registerTool(name, { ...sdkConfig, inputSchema: inputShape, annotations }, finalHandler);
227
+ return server.registerTool(name, { ...sdkConfig, inputSchema: inputShape, annotations }, instrumented);
199
228
  });
200
229
  }
201
230
  registerMeta = ((name, config, handler) => {
@@ -225,6 +254,51 @@ export class ToolRegistry {
225
254
  this.argShapeCache.set(name, shape);
226
255
  return shape;
227
256
  }
257
+ /** Declared argument keys for a tool, in declaration order; undefined when
258
+ * the tool is not registered. Backs unknown-argument screening, which needs
259
+ * the full key list rather than argShape's scalar-kind subset view. */
260
+ declaredKeys(name) {
261
+ const entry = this.tools.find((t) => t.name === name);
262
+ return entry ? Object.keys(entry.inputShape) : undefined;
263
+ }
264
+ /** Keys spelling a similar concept elsewhere in the same service, for the
265
+ * hint on a call whose key matched nothing. Kept only when a key is declared
266
+ * by at least two tools in the service or by a curated one, so one-off
267
+ * generated parameters do not become advice. */
268
+ siblingSpellings(tool, unknownKeys) {
269
+ const self = this.tools.find((t) => t.name === tool);
270
+ if (!self)
271
+ return [];
272
+ const declared = new Set(Object.keys(self.inputShape));
273
+ const byKey = new Map();
274
+ for (const t of this.tools) {
275
+ // Only tools the agent can actually see in tools/list. A hint naming a
276
+ // registered-but-unadvertised generated tool turns a recoverable error
277
+ // into a dead end: in curated mode 353 tools are registered and 181 are
278
+ // advertised, and the hint used to reach for any of them.
279
+ if (t.service !== self.service || t.name === tool || !this.isVisible(t))
280
+ continue;
281
+ for (const key of Object.keys(t.inputShape)) {
282
+ if (declared.has(key))
283
+ continue;
284
+ const e = byKey.get(key) ?? { tools: [], curated: false };
285
+ e.tools.push(t.name);
286
+ if (!t.generated)
287
+ e.curated = true;
288
+ byKey.set(key, e);
289
+ }
290
+ }
291
+ const candidates = [...byKey.entries()].filter(([, e]) => e.tools.length >= 2 || e.curated);
292
+ // Reuse the tiered matcher rather than a substring test: the motivating
293
+ // case (parentId against parentFolderId) fails containment and edit
294
+ // distance alike, which is the whole reason that matcher exists.
295
+ const keys = candidates.map(([key]) => key);
296
+ const hits = new Set(unknownKeys.flatMap((k) => suggestKeys(k, keys)));
297
+ return candidates
298
+ .filter(([key]) => hits.has(key))
299
+ .slice(0, 2)
300
+ .map(([key, e]) => ({ key, tools: e.tools }));
301
+ }
228
302
  catalog(service, query) {
229
303
  const q = query?.trim().toLowerCase();
230
304
  return this.tools
@@ -235,8 +309,38 @@ export class ToolRegistry {
235
309
  summary: t.description,
236
310
  args: Object.keys(t.inputShape),
237
311
  cud: t.cud,
312
+ ...(this.isUngrantable(t) ? { unreachable: true } : {}),
238
313
  }));
239
314
  }
315
+ /** The metrics recorder, for hook sites (escape hatch); null when off. */
316
+ get usageMetrics() {
317
+ return this.metrics;
318
+ }
319
+ /** Every registered tool name, hidden ones included: they stay callable, so
320
+ * a did-you-mean may legitimately point at one. */
321
+ toolNames() {
322
+ return this.tools.map((t) => t.name);
323
+ }
324
+ /** Membership test against the REGISTERED tool set (hidden tools included:
325
+ * they stay callable). The metrics tap uses this so a client-supplied name
326
+ * can never enter the closed vocabulary. */
327
+ hasTool(name) {
328
+ return this.tools.some((t) => t.name === name);
329
+ }
330
+ /** Op-name vocabulary for a service, split by provenance so the capped
331
+ * discover descriptions can list curated ops and only summarize the
332
+ * generated long tail. */
333
+ opNames(service) {
334
+ const strip = (n) => (n.startsWith(`${service}_`) ? n.slice(service.length + 1) : n);
335
+ const curated = [];
336
+ const generated = [];
337
+ for (const t of this.tools) {
338
+ if (t.meta || t.service !== service)
339
+ continue;
340
+ (t.generated ? generated : curated).push(strip(t.name));
341
+ }
342
+ return { curated: [...new Set(curated)], generated: [...new Set(generated)] };
343
+ }
240
344
  reveal(service) {
241
345
  if (this.revealed.has(service))
242
346
  return false;
@@ -269,8 +373,40 @@ export class ToolRegistry {
269
373
  return this.mode;
270
374
  return this.expanded ? 'curated' : 'lazy';
271
375
  }
376
+ /**
377
+ * True when NO bundle in the catalog grants any of the method's alternative
378
+ * scopes, so the call cannot succeed on any account under any configuration.
379
+ * Static: it asks the catalog, not an account, so it is safe to cache.
380
+ * `classifyScope` with nothing granted and no profile reports `add_bundle`
381
+ * for a scope some bundle could supply and `unknown_scope` only when none
382
+ * can, which is exactly the distinction wanted here.
383
+ */
384
+ isUngrantable(tool) {
385
+ if (!tool.requiredScopes || tool.requiredScopes.length === 0)
386
+ return false;
387
+ const cached = this.ungrantable.get(tool.name);
388
+ if (cached !== undefined)
389
+ return cached;
390
+ // EVERY alternative must be ungrantable. `classifyMethodScopes` cannot
391
+ // answer this: `add_bundle` and `unknown_scope` share the
392
+ // `not_requestable` rank, so its first-best match hides a grantable
393
+ // alternative that appears later in the list. Discovery scope lists are
394
+ // ANY-OF, so one grantable alternative makes the method reachable.
395
+ const verdict = tool.requiredScopes.every((scope) => {
396
+ const c = classifyScope(scope, EMPTY_SCOPES, EMPTY_SCOPES);
397
+ return c.state === 'not_requestable' && c.reason === 'unknown_scope';
398
+ });
399
+ this.ungrantable.set(tool.name, verdict);
400
+ return verdict;
401
+ }
272
402
  isVisible(tool) {
273
403
  const mode = this.effectiveMode();
404
+ // Advertising a tool that can never succeed spends context budget to hand
405
+ // the agent a dead end. It stays registered and callable, so calling it by
406
+ // name still produces the real scope error plus its remediation, and
407
+ // {service}_discover still lists it, marked.
408
+ if (!tool.meta && this.isUngrantable(tool))
409
+ return false;
274
410
  return (tool.meta ||
275
411
  mode === 'eager' ||
276
412
  this.revealed.has(tool.service) ||
@@ -287,7 +423,11 @@ export class ToolRegistry {
287
423
  if (this.tools.length === 0) {
288
424
  throw new Error('installListHandler() requires at least one registered tool');
289
425
  }
290
- this.server.server.setRequestHandler(ListToolsRequestSchema, async () => ({
426
+ this.server.server.setRequestHandler('tools/list', async () => ({
427
+ // The wire Tool is hand-built because the SDK's own types drop the
428
+ // anthropic/* _meta keys honoring clients read. z.toJSONSchema emits a
429
+ // valid draft-7 object schema by construction, which the SDK's recursive
430
+ // JSON-Schema type cannot infer from our cached `unknown`.
291
431
  tools: this.tools.filter((t) => this.isVisible(t)).map((t) => this.toToolJson(t)),
292
432
  }));
293
433
  }
@@ -108,6 +108,22 @@ export const BUNDLE_CATALOG = {
108
108
  description: 'Read Gmail Postmaster Tools deliverability data.',
109
109
  risk: 'low',
110
110
  },
111
+ analytics: {
112
+ scopes: ['https://www.googleapis.com/auth/analytics.readonly'],
113
+ description: 'Read Google Analytics (GA4): run reports and inspect accounts, properties and their configuration.',
114
+ risk: 'low',
115
+ },
116
+ // Includes readonly so it is self-sufficient: analytics.edit alone does not
117
+ // authorize Data API reads. The smaller `analytics` bundle stays the hint
118
+ // target for read scopes (scope-observability sorts bundles by size).
119
+ analytics_write: {
120
+ scopes: [
121
+ 'https://www.googleapis.com/auth/analytics.readonly',
122
+ 'https://www.googleapis.com/auth/analytics.edit',
123
+ ],
124
+ description: 'Edit Google Analytics (GA4) configuration: properties, data streams, key events, custom dimensions and metrics. Includes read access.',
125
+ risk: 'medium',
126
+ },
111
127
  groupssettings: {
112
128
  scopes: ['https://www.googleapis.com/auth/apps.groups.settings'],
113
129
  description: 'Change Google Groups settings for the domain.',
@@ -9,3 +9,11 @@ export declare const GENERATED_GATES: Record<string, {
9
9
  enabled: () => boolean;
10
10
  hint: string;
11
11
  }>;
12
+ /**
13
+ * Message for a `tools/call` naming a tool that is not registered. The SDK's
14
+ * own answer is a bare "Tool X not found", which reads identically for a typo,
15
+ * for a service gated behind a scope bundle, and for an API this server has
16
+ * never heard of. Those need different next steps, and the server knows which
17
+ * is which: it composed the enabled-service list at boot.
18
+ */
19
+ export declare function unknownToolMessage(registry: ToolRegistry, name: string): string;
package/dist/services.js CHANGED
@@ -11,7 +11,10 @@ import { registerSlidesTools } from './tools/slides.js';
11
11
  import { registerFormsTools } from './tools/forms.js';
12
12
  import { registerChatTools } from './tools/chat.js';
13
13
  import { registerAdminTools } from './tools/admin.js';
14
+ import { registerAnalyticsTools } from './tools/analytics.js';
14
15
  import { getOptionalBundles, getAdminAccounts } from './auth.js';
16
+ import { GENERATED_SERVICES } from './tools/generated/index.js';
17
+ import { suggestKeys } from './arg-strict.js';
15
18
  export const SERVICES = [
16
19
  { name: 'gmail', register: registerGmailTools },
17
20
  { name: 'drive', register: registerDriveTools },
@@ -25,9 +28,10 @@ export const SERVICES = [
25
28
  { name: 'slides', register: registerSlidesTools, enabled: () => new Set(getOptionalBundles()).has('slides') },
26
29
  { name: 'forms', register: registerFormsTools, enabled: () => new Set(getOptionalBundles()).has('forms') },
27
30
  { name: 'chat', register: registerChatTools, enabled: () => new Set(getOptionalBundles()).has('chat') },
31
+ { name: 'analytics', register: registerAnalyticsTools, enabled: () => { const b = new Set(getOptionalBundles()); return b.has('analytics') || b.has('analytics_write'); } },
28
32
  { name: 'admin', register: registerAdminTools, enabled: () => getAdminAccounts().length > 0 },
29
33
  ];
30
- // Generated-only services with opt-in scopes; admin/forms/chat reuse their curated gate in buildRegistry,
34
+ // Generated-only services with opt-in scopes; admin/forms/chat/analytics reuse their curated gate in buildRegistry,
31
35
  // and workspaceevents is deliberately absent — no dedicated scope (subscriptions use resource scopes).
32
36
  const bundleGate = (name) => ({
33
37
  enabled: () => new Set(getOptionalBundles()).has(name),
@@ -49,3 +53,26 @@ export const GENERATED_GATES = {
49
53
  script: bundleGate('script'),
50
54
  vault: bundleGate('vault'),
51
55
  };
56
+ /**
57
+ * Message for a `tools/call` naming a tool that is not registered. The SDK's
58
+ * own answer is a bare "Tool X not found", which reads identically for a typo,
59
+ * for a service gated behind a scope bundle, and for an API this server has
60
+ * never heard of. Those need different next steps, and the server knows which
61
+ * is which: it composed the enabled-service list at boot.
62
+ */
63
+ export function unknownToolMessage(registry, name) {
64
+ const near = suggestKeys(name, registry.toolNames(), 3);
65
+ if (near.length > 0)
66
+ return `Tool ${name} not found. Did you mean: ${near.join(', ')}?`;
67
+ // A service that EXISTS in the build but registered nothing is gated, not
68
+ // misspelled, and the fix is a scope bundle rather than a different name.
69
+ const service = name.split('_')[0];
70
+ const known = SERVICES.some((s) => s.name === service) || GENERATED_SERVICES.some((s) => s.name === service);
71
+ if (known && !registry.services().includes(service)) {
72
+ const hint = service === 'admin'
73
+ ? 'set admin on an account/profile (or GOOGLE_ADMIN_ACCOUNTS), then re-auth'
74
+ : (GENERATED_GATES[service]?.hint ?? `add "${service}" to an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES), then re-auth`);
75
+ return `Tool ${name} not found: the "${service}" service is not enabled in this deployment. To enable it, ${hint}. Until then, google_api_call can reach the same API.`;
76
+ }
77
+ return `Tool ${name} not found. Call {service}_discover to list a service's tools, or google_api_search to find a method on any Google API.`;
78
+ }
@@ -1,4 +1,4 @@
1
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
2
  export interface SetupOptions {
3
3
  /** Public base URL when serving over HTTP; used for the Web client redirect. */
4
4
  publicUrl?: string;
@@ -47,7 +47,7 @@ export function registerSetupPrompt(server) {
47
47
  server.registerPrompt('setup', {
48
48
  title: 'Set up mcp-google-multi',
49
49
  description: 'Guided Google Cloud Console prelude: project, APIs, consent screen, OAuth client, and credentials. The one-time browser setup that has no API.',
50
- argsSchema: { publicUrl: z.string().optional().describe('Public base URL when serving over HTTP (for the Web OAuth client redirect). Omit for stdio.') },
50
+ argsSchema: z.object({ publicUrl: z.string().optional().describe('Public base URL when serving over HTTP (for the Web OAuth client redirect). Omit for stdio.') }),
51
51
  }, (args) => ({
52
52
  messages: [
53
53
  {
@@ -1,4 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  export declare function coerceArray<T extends z.ZodTypeAny>(element: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<T>>;
3
3
  export declare function coerceJson<T extends z.ZodTypeAny>(schema: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, T>;
4
+ /** Numeric args arrive string-encoded from some clients. Arg normalization
5
+ * runs on BOTH transports, but it only coerces keys it RENAMED, so a value
6
+ * sent under the correct key never passes through it and the coercion has to
7
+ * live in the schema. */
8
+ export declare function coerceNumber<T extends z.ZodTypeAny>(schema: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, T>;
4
9
  export declare const coerceBoolean: z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodBoolean>;
@@ -22,6 +22,17 @@ export function coerceArray(element) {
22
22
  export function coerceJson(schema) {
23
23
  return z.preprocess((val) => (typeof val === 'string' ? parseJsonLoose(val) : val), schema);
24
24
  }
25
+ /** Numeric args arrive string-encoded from some clients. Arg normalization
26
+ * runs on BOTH transports, but it only coerces keys it RENAMED, so a value
27
+ * sent under the correct key never passes through it and the coercion has to
28
+ * live in the schema. */
29
+ export function coerceNumber(schema) {
30
+ return z.preprocess((val) => {
31
+ if (typeof val === 'string' && val.trim() !== '' && !Number.isNaN(Number(val)))
32
+ return Number(val);
33
+ return val;
34
+ }, schema);
35
+ }
25
36
  export const coerceBoolean = z.preprocess((val) => {
26
37
  if (typeof val === 'boolean')
27
38
  return val;
@@ -1,16 +1,34 @@
1
1
  import type { Account } from '../accounts.js';
2
+ /** A 403 means "missing scope" and "you cannot touch this resource" alike, and
3
+ * a single hint slot for both is what told callers to add a bundle they had
4
+ * already granted. Services now supply one hint per meaning. A bare string
5
+ * stays accepted and keeps its old meaning, the scope one. */
6
+ export interface ServiceHints {
7
+ scope?: string;
8
+ resource?: string;
9
+ }
2
10
  export interface ErrorEnvelope {
3
11
  error: string;
4
12
  message: string;
5
13
  hint?: string;
6
14
  retriable: boolean;
7
- account: string;
15
+ /** Optional because some failures genuinely precede account resolution (an
16
+ * undeclared argument, a wizard step before the alias is known). Every
17
+ * producer that HAS an account must still pass it. */
18
+ account?: string;
8
19
  }
9
- export declare function mapGoogleError(error: any, account: Account, forbiddenHint?: string, scopeContext?: () => {
20
+ /** The ONE extraction point for every Google-originated message. Without the
21
+ * raw-body guard, gaxios copies the entire response body into error.message,
22
+ * which put 8.5 KB of Google's HTML front-end page inside a JSON envelope. */
23
+ export declare function safeMessage(error: any, payload?: any, status?: number | undefined): string;
24
+ export declare function mapGoogleError(error: any, account: Account, serviceHints?: string | ServiceHints, scopeContext?: () => {
10
25
  hint: string;
11
26
  retriable: boolean;
12
27
  } | null): ErrorEnvelope;
13
- export declare function handleGoogleApiError(error: any, account: Account, forbiddenHint?: string, scopeContext?: () => {
28
+ /** Bound the SERIALIZED envelope, not just the message: an authored hint can
29
+ * be long too, and the error path had no cap at all before this. */
30
+ export declare function stringifyEnvelope(envelope: ErrorEnvelope): string;
31
+ export declare function handleGoogleApiError(error: any, account: Account, serviceHints?: string | ServiceHints, scopeContext?: () => {
14
32
  hint: string;
15
33
  retriable: boolean;
16
34
  } | null): {
@@ -20,3 +38,13 @@ export declare function handleGoogleApiError(error: any, account: Account, forbi
20
38
  }[];
21
39
  isError: true;
22
40
  };
41
+ /** Local argument rejection, before any Google call. Same envelope as
42
+ * mapGoogleError so a caller parses one shape. The slug literal stays inline
43
+ * because the KNOWN_ERROR_SLUGS honesty test greps for `error: '<slug>'`. */
44
+ export declare function invalidParams(account: Account | undefined, message: string, hint: string): {
45
+ content: {
46
+ type: "text";
47
+ text: string;
48
+ }[];
49
+ isError: true;
50
+ };