mcp-google-multi 6.0.0-alpha.9 → 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 +6 -4
  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 +76 -0
  6. package/dist/arg-normalize.js +314 -0
  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 +13 -1
  23. package/dist/http-transport.js +10 -4
  24. package/dist/index.js +113 -10
  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 +54 -2
  34. package/dist/registry.js +180 -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 +12 -4
@@ -0,0 +1,108 @@
1
+ // Opt-in outbound recipient allowlist for unattended deployments: a
2
+ // prompt-injection blast-radius control (a hijacked agent cannot mail, invite
3
+ // or share outside the operator's list), a safety feature first and an
4
+ // enterprise checkbox second. FREE-core forever (ee-definition-v0 handoff).
5
+ // Off by default: an unset/empty GOOGLE_OUTBOUND_ALLOWLIST gates nothing.
6
+ /** Parse GOOGLE_OUTBOUND_ALLOWLIST: comma-separated addresses and @domain
7
+ * suffixes ("a@b.com, @company.com"). null = feature off (nothing gated). */
8
+ export function resolveOutboundAllowlist(env = process.env) {
9
+ const raw = env.GOOGLE_OUTBOUND_ALLOWLIST;
10
+ if (raw === undefined || raw.trim() === '')
11
+ return null;
12
+ const entries = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
13
+ if (entries.length === 0)
14
+ return null;
15
+ const exact = new Set(entries.filter((e) => !e.startsWith('@')));
16
+ const domains = entries.filter((e) => e.startsWith('@'));
17
+ return {
18
+ entries,
19
+ allows(email) {
20
+ const a = email.trim().toLowerCase();
21
+ if (exact.has(a))
22
+ return true;
23
+ return domains.some((d) => a.endsWith(d));
24
+ },
25
+ };
26
+ }
27
+ /** The addresses in `emails` the active allowlist rejects; [] when off. */
28
+ export function outboundViolations(emails, env = process.env) {
29
+ const list = resolveOutboundAllowlist(env);
30
+ if (!list)
31
+ return [];
32
+ return emails.map((e) => e.trim()).filter(Boolean).filter((e) => !list.allows(e));
33
+ }
34
+ /** The standard error envelope for a blocked outbound target. */
35
+ export function outboundDeniedEnvelope(kind, blocked, account, env = process.env) {
36
+ const list = resolveOutboundAllowlist(env);
37
+ return {
38
+ content: [
39
+ {
40
+ type: 'text',
41
+ text: JSON.stringify({
42
+ error: 'recipient_not_allowed',
43
+ message: `${kind} blocked by the outbound allowlist: ${blocked.join(', ')}.`,
44
+ hint: `GOOGLE_OUTBOUND_ALLOWLIST is active (${(list?.entries ?? []).join(', ')}). ` +
45
+ 'The operator must add the address (or its @domain) to the list, or unset the variable, to allow this target.',
46
+ retriable: false,
47
+ account,
48
+ }),
49
+ },
50
+ ],
51
+ isError: true,
52
+ };
53
+ }
54
+ /** Convenience: envelope when violations exist, else null. */
55
+ export function checkOutbound(kind, emails, account, env = process.env) {
56
+ const blocked = outboundViolations(emails, env);
57
+ return blocked.length > 0 ? outboundDeniedEnvelope(kind, blocked, account, env) : null;
58
+ }
59
+ // Escape-hatch / generated-tool enforcement. Structured bodies are inspected
60
+ // for the known recipient fields; RAW compose methods cannot be inspected
61
+ // (base64 RFC 822), so with the allowlist active they are refused outright in
62
+ // favor of the curated tools that enforce it.
63
+ const RAW_SEND_METHODS = new Set(['gmail.users.messages.send', 'gmail.users.drafts.send', 'gmail.users.drafts.create', 'gmail.users.messages.insert', 'gmail.users.messages.import']);
64
+ export function collectBodyRecipients(body) {
65
+ const out = [];
66
+ const walk = (v, depth) => {
67
+ if (!v || typeof v !== 'object' || depth > 4)
68
+ return;
69
+ if (Array.isArray(v)) {
70
+ for (const x of v)
71
+ walk(x, depth + 1);
72
+ return;
73
+ }
74
+ for (const [k, x] of Object.entries(v)) {
75
+ if ((k === 'emailAddress' || k === 'email') && typeof x === 'string' && x.includes('@'))
76
+ out.push(x);
77
+ else if (k === 'attendees' || k === 'permissions')
78
+ walk(x, depth + 1);
79
+ else if (typeof x === 'object')
80
+ walk(x, depth + 1);
81
+ }
82
+ };
83
+ walk(body, 0);
84
+ return out;
85
+ }
86
+ /** Gate one escape-hatch/generated dispatch; envelope when blocked, else null. */
87
+ export function checkOutboundForMethod(methodId, body, account, env = process.env) {
88
+ if (!resolveOutboundAllowlist(env))
89
+ return null;
90
+ if (RAW_SEND_METHODS.has(methodId)) {
91
+ return {
92
+ content: [
93
+ {
94
+ type: 'text',
95
+ text: JSON.stringify({
96
+ error: 'recipient_not_allowed',
97
+ message: `${methodId} carries an uninspectable raw message while GOOGLE_OUTBOUND_ALLOWLIST is active.`,
98
+ hint: 'Use gmail_send / gmail_create_draft, which enforce the allowlist on parsed recipients.',
99
+ retriable: false,
100
+ account,
101
+ }),
102
+ },
103
+ ],
104
+ isError: true,
105
+ };
106
+ }
107
+ return checkOutbound(`${methodId} recipient`, collectBodyRecipients(body), account, env);
108
+ }
@@ -1,6 +1,8 @@
1
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
2
  import { z } from 'zod';
3
3
  import { type Policy } from './write-control.js';
4
+ import type { ArgShape } from './arg-normalize.js';
5
+ import type { Metrics } from './usage-metrics.js';
4
6
  export type Cud = 'read' | 'create' | 'update' | 'delete';
5
7
  export type DiscoveryMode = 'lazy' | 'curated' | 'eager';
6
8
  export declare function resolveDiscoveryMode(env?: NodeJS.ProcessEnv): DiscoveryMode;
@@ -29,15 +31,25 @@ export interface CatalogOperation {
29
31
  summary: string;
30
32
  args: string[];
31
33
  cud: Cud;
34
+ /** Present only when no scope bundle in the catalog can authorize this
35
+ * method, so the call cannot succeed on any account however it is
36
+ * configured. The tool stays callable: graceful dispatch then returns the
37
+ * real scope error rather than a bare "not found". */
38
+ unreachable?: true;
32
39
  }
33
40
  export declare function inferCud(name: string): Cud;
34
41
  export declare class ToolRegistry {
35
42
  private readonly server;
43
+ private readonly metrics;
36
44
  readonly tools: ToolEntry[];
37
45
  readonly policy: Policy;
38
46
  readonly registerTool: McpServer['registerTool'];
39
47
  private readonly revealed;
40
48
  private readonly jsonSchemaCache;
49
+ private readonly argShapeCache;
50
+ /** Memoized isUngrantable verdict per tool: the answer depends on the frozen
51
+ * bundle catalog only, so it never changes within a process. */
52
+ private readonly ungrantable;
41
53
  private readonly compactOutput;
42
54
  private registeringMeta;
43
55
  /** Configured visibility mode (GOOGLE_DISCOVERY); default lazy = v5 exact. */
@@ -45,10 +57,41 @@ export declare class ToolRegistry {
45
57
  /** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
46
58
  * lazy surface to curated without touching the configured mode. */
47
59
  private expanded;
48
- constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode);
60
+ constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode, metrics?: Metrics | null);
49
61
  registerMeta: McpServer['registerTool'];
50
62
  services(): string[];
63
+ /** Declared input-schema keys + scalar kinds for one tool (tools/call arg
64
+ * normalization; the kind drives value coercion on renamed keys). */
65
+ argShape(name: string): ArgShape | undefined;
66
+ /** Declared argument keys for a tool, in declaration order; undefined when
67
+ * the tool is not registered. Backs unknown-argument screening, which needs
68
+ * the full key list rather than argShape's scalar-kind subset view. */
69
+ declaredKeys(name: string): readonly string[] | undefined;
70
+ /** Keys spelling a similar concept elsewhere in the same service, for the
71
+ * hint on a call whose key matched nothing. Kept only when a key is declared
72
+ * by at least two tools in the service or by a curated one, so one-off
73
+ * generated parameters do not become advice. */
74
+ siblingSpellings(tool: string, unknownKeys: string[]): Array<{
75
+ key: string;
76
+ tools: string[];
77
+ }>;
51
78
  catalog(service: string, query?: string): CatalogOperation[];
79
+ /** The metrics recorder, for hook sites (escape hatch); null when off. */
80
+ get usageMetrics(): Metrics | null;
81
+ /** Every registered tool name, hidden ones included: they stay callable, so
82
+ * a did-you-mean may legitimately point at one. */
83
+ toolNames(): string[];
84
+ /** Membership test against the REGISTERED tool set (hidden tools included:
85
+ * they stay callable). The metrics tap uses this so a client-supplied name
86
+ * can never enter the closed vocabulary. */
87
+ hasTool(name: string): boolean;
88
+ /** Op-name vocabulary for a service, split by provenance so the capped
89
+ * discover descriptions can list curated ops and only summarize the
90
+ * generated long tail. */
91
+ opNames(service: string): {
92
+ curated: string[];
93
+ generated: string[];
94
+ };
52
95
  reveal(service: string): boolean;
53
96
  /** discover_all: advertise the full curated set at once. Idempotent. */
54
97
  expand(): boolean;
@@ -58,6 +101,15 @@ export declare class ToolRegistry {
58
101
  * dispatch), zero correctness impact. */
59
102
  collapse(): boolean;
60
103
  private effectiveMode;
104
+ /**
105
+ * True when NO bundle in the catalog grants any of the method's alternative
106
+ * scopes, so the call cannot succeed on any account under any configuration.
107
+ * Static: it asks the catalog, not an account, so it is safe to cache.
108
+ * `classifyScope` with nothing granted and no profile reports `add_bundle`
109
+ * for a scope some bundle could supply and `unknown_scope` only when none
110
+ * can, which is exactly the distinction wanted here.
111
+ */
112
+ isUngrantable(tool: ToolEntry): boolean;
61
113
  isVisible(tool: ToolEntry): boolean;
62
114
  visibleCount(): {
63
115
  eager: number;
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,28 @@ 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']);
50
+ /** Unwrap optional/default/nullable to the declared scalar kind (zod 4 defs). */
51
+ function scalarKindOf(field) {
52
+ let cur = field;
53
+ for (let i = 0; i < 4 && cur?._zod?.def; i++) {
54
+ const def = cur._zod.def;
55
+ if (def.type === 'number')
56
+ return 'number';
57
+ if (def.type === 'boolean')
58
+ return 'boolean';
59
+ if (def.type === 'optional' || def.type === 'default' || def.type === 'nullable') {
60
+ cur = def.innerType;
61
+ continue;
62
+ }
63
+ return 'other';
64
+ }
65
+ return 'other';
66
+ }
41
67
  function isAccountEnum(field) {
42
68
  const def = field?._zod?.def;
43
69
  if (!def)
@@ -64,11 +90,16 @@ export function inferCud(name) {
64
90
  }
65
91
  export class ToolRegistry {
66
92
  server;
93
+ metrics;
67
94
  tools = [];
68
95
  policy;
69
96
  registerTool;
70
97
  revealed = new Set();
71
98
  jsonSchemaCache = new Map();
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();
72
103
  compactOutput = trimEnabled();
73
104
  registeringMeta = false;
74
105
  /** Configured visibility mode (GOOGLE_DISCOVERY); default lazy = v5 exact. */
@@ -76,8 +107,9 @@ export class ToolRegistry {
76
107
  /** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
77
108
  * lazy surface to curated without touching the configured mode. */
78
109
  expanded = false;
79
- constructor(server, policy, mode = resolveDiscoveryMode()) {
110
+ constructor(server, policy, mode = resolveDiscoveryMode(), metrics = null) {
80
111
  this.server = server;
112
+ this.metrics = metrics;
81
113
  this.policy = policy;
82
114
  this.mode = mode;
83
115
  this.registerTool = ((name, config, handler) => {
@@ -107,7 +139,7 @@ export class ToolRegistry {
107
139
  // never fan out meta tools: google_api_call infers cud=read but executes writes
108
140
  let inputShape = config.inputSchema ?? {};
109
141
  let baseHandler = handler;
110
- const hasAccountField = 'account' in inputShape;
142
+ const hasAccountField = 'account' in inputShape && !DEFAULT_ACCOUNT_EXCLUDE.has(name);
111
143
  if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) && isAccountEnum(inputShape.account)) {
112
144
  const description = inputShape.account.description ?? 'Google account alias';
113
145
  inputShape = { ...inputShape, account: fanoutAccountField(description) };
@@ -136,9 +168,11 @@ export class ToolRegistry {
136
168
  });
137
169
  const guarded = cud === 'read'
138
170
  ? baseHandler
139
- : (...args) => isAllowed({ name, service, cud }, policy)
171
+ : (...args) => isAllowed({ name, service, cud, scopes: config.requiredScopes }, policy)
140
172
  ? baseHandler(...args)
141
- : 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);
142
176
  // A2: the ONE default-account injection site — outside the CUD gate and
143
177
  // the fan-out parse so both observe a concrete alias; NOT meta-skipped
144
178
  // (that is what covers google_api_call with zero bespoke code). Explicit
@@ -176,8 +210,21 @@ export class ToolRegistry {
176
210
  const finalHandler = this.compactOutput
177
211
  ? async (...args) => compactResult(await withDefault(...args))
178
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;
179
226
  const { cud: _cud, ...sdkConfig } = config;
180
- return server.registerTool(name, { ...sdkConfig, inputSchema: inputShape, annotations }, finalHandler);
227
+ return server.registerTool(name, { ...sdkConfig, inputSchema: inputShape, annotations }, instrumented);
181
228
  });
182
229
  }
183
230
  registerMeta = ((name, config, handler) => {
@@ -192,6 +239,66 @@ export class ToolRegistry {
192
239
  services() {
193
240
  return [...new Set(this.tools.filter((t) => !t.meta).map((t) => t.service))];
194
241
  }
242
+ /** Declared input-schema keys + scalar kinds for one tool (tools/call arg
243
+ * normalization; the kind drives value coercion on renamed keys). */
244
+ argShape(name) {
245
+ const cached = this.argShapeCache.get(name);
246
+ if (cached)
247
+ return cached;
248
+ const entry = this.tools.find((t) => t.name === name);
249
+ if (!entry)
250
+ return undefined;
251
+ const shape = new Map();
252
+ for (const [key, field] of Object.entries(entry.inputShape))
253
+ shape.set(key, scalarKindOf(field));
254
+ this.argShapeCache.set(name, shape);
255
+ return shape;
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
+ }
195
302
  catalog(service, query) {
196
303
  const q = query?.trim().toLowerCase();
197
304
  return this.tools
@@ -202,8 +309,38 @@ export class ToolRegistry {
202
309
  summary: t.description,
203
310
  args: Object.keys(t.inputShape),
204
311
  cud: t.cud,
312
+ ...(this.isUngrantable(t) ? { unreachable: true } : {}),
205
313
  }));
206
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
+ }
207
344
  reveal(service) {
208
345
  if (this.revealed.has(service))
209
346
  return false;
@@ -236,8 +373,40 @@ export class ToolRegistry {
236
373
  return this.mode;
237
374
  return this.expanded ? 'curated' : 'lazy';
238
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
+ }
239
402
  isVisible(tool) {
240
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;
241
410
  return (tool.meta ||
242
411
  mode === 'eager' ||
243
412
  this.revealed.has(tool.service) ||
@@ -254,7 +423,11 @@ export class ToolRegistry {
254
423
  if (this.tools.length === 0) {
255
424
  throw new Error('installListHandler() requires at least one registered tool');
256
425
  }
257
- 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`.
258
431
  tools: this.tools.filter((t) => this.isVisible(t)).map((t) => this.toToolJson(t)),
259
432
  }));
260
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;