mcp-google-multi 6.0.0-alpha.5 → 6.0.0-alpha.51

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 (163) hide show
  1. package/README.md +6 -4
  2. package/dist/accounts.d.ts +16 -1
  3. package/dist/accounts.js +38 -12
  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.d.ts +3 -2
  10. package/dist/auth.js +21 -80
  11. package/dist/boot-gates.d.ts +26 -0
  12. package/dist/boot-gates.js +38 -0
  13. package/dist/client.d.ts +28 -2
  14. package/dist/client.js +0 -0
  15. package/dist/compose.d.ts +5 -0
  16. package/dist/compose.js +67 -0
  17. package/dist/config-file.d.ts +14 -0
  18. package/dist/config-file.js +40 -1
  19. package/dist/discover.js +35 -14
  20. package/dist/discovery-client.d.ts +8 -1
  21. package/dist/discovery-client.js +96 -17
  22. package/dist/doctor.d.ts +27 -3
  23. package/dist/doctor.js +171 -28
  24. package/dist/env-load.d.ts +11 -0
  25. package/dist/env-load.js +23 -1
  26. package/dist/executor.js +12 -1
  27. package/dist/fanout.d.ts +1 -1
  28. package/dist/fanout.js +49 -11
  29. package/dist/http-transport.d.ts +27 -3
  30. package/dist/http-transport.js +52 -12
  31. package/dist/identity.d.ts +3 -1
  32. package/dist/identity.js +14 -5
  33. package/dist/index.js +150 -77
  34. package/dist/master-key.js +11 -5
  35. package/dist/mcp-token.d.ts +22 -6
  36. package/dist/mcp-token.js +27 -6
  37. package/dist/metrics-cli.d.ts +21 -0
  38. package/dist/metrics-cli.js +206 -0
  39. package/dist/metrics-tap.d.ts +12 -0
  40. package/dist/metrics-tap.js +95 -0
  41. package/dist/oauth-as.d.ts +31 -0
  42. package/dist/oauth-as.js +64 -10
  43. package/dist/oauth-consent.d.ts +25 -10
  44. package/dist/oauth-consent.js +85 -39
  45. package/dist/outbound-allowlist.d.ts +34 -0
  46. package/dist/outbound-allowlist.js +108 -0
  47. package/dist/registry-transfer.d.ts +5 -0
  48. package/dist/registry-transfer.js +65 -5
  49. package/dist/registry.d.ts +66 -2
  50. package/dist/registry.js +237 -16
  51. package/dist/scope-catalog.js +16 -0
  52. package/dist/scope-observability.js +2 -1
  53. package/dist/services.d.ts +16 -3
  54. package/dist/services.js +33 -6
  55. package/dist/setup-prompt.d.ts +1 -1
  56. package/dist/setup-prompt.js +1 -1
  57. package/dist/tenant-purge.d.ts +26 -0
  58. package/dist/tenant-purge.js +67 -0
  59. package/dist/token-store.d.ts +20 -2
  60. package/dist/token-store.js +0 -0
  61. package/dist/tools/_coerce.d.ts +5 -0
  62. package/dist/tools/_coerce.js +11 -0
  63. package/dist/tools/_errors.d.ts +31 -3
  64. package/dist/tools/_errors.js +307 -43
  65. package/dist/tools/_local-files.js +7 -2
  66. package/dist/tools/account-wizard.d.ts +33 -1
  67. package/dist/tools/account-wizard.js +203 -58
  68. package/dist/tools/accounts-tool.js +3 -2
  69. package/dist/tools/admin.d.ts +2 -1
  70. package/dist/tools/admin.js +20 -15
  71. package/dist/tools/analytics.d.ts +19 -0
  72. package/dist/tools/analytics.js +288 -0
  73. package/dist/tools/calendar.d.ts +9 -1
  74. package/dist/tools/calendar.js +93 -29
  75. package/dist/tools/chat.d.ts +2 -1
  76. package/dist/tools/chat.js +18 -11
  77. package/dist/tools/contacts.d.ts +2 -1
  78. package/dist/tools/contacts.js +74 -43
  79. package/dist/tools/docs.d.ts +2 -1
  80. package/dist/tools/docs.js +90 -84
  81. package/dist/tools/drive.d.ts +3 -1
  82. package/dist/tools/drive.js +218 -130
  83. package/dist/tools/forms.d.ts +2 -1
  84. package/dist/tools/forms.js +21 -17
  85. package/dist/tools/generated/_shared.d.ts +8 -1
  86. package/dist/tools/generated/_shared.js +17 -4
  87. package/dist/tools/generated/admin.d.ts +2 -1
  88. package/dist/tools/generated/admin.js +610 -479
  89. package/dist/tools/generated/analytics.d.ts +3 -0
  90. package/dist/tools/generated/analytics.js +981 -0
  91. package/dist/tools/generated/appsmarket.d.ts +2 -1
  92. package/dist/tools/generated/appsmarket.js +9 -9
  93. package/dist/tools/generated/calendar.d.ts +2 -1
  94. package/dist/tools/generated/calendar.js +83 -83
  95. package/dist/tools/generated/chat.d.ts +2 -1
  96. package/dist/tools/generated/chat.js +178 -151
  97. package/dist/tools/generated/classroom.d.ts +2 -1
  98. package/dist/tools/generated/classroom.js +467 -425
  99. package/dist/tools/generated/cloudidentity.d.ts +2 -1
  100. package/dist/tools/generated/cloudidentity.js +209 -201
  101. package/dist/tools/generated/cloudsearch.d.ts +2 -1
  102. package/dist/tools/generated/cloudsearch.js +139 -138
  103. package/dist/tools/generated/contacts.d.ts +2 -1
  104. package/dist/tools/generated/contacts.js +49 -40
  105. package/dist/tools/generated/drive.d.ts +2 -1
  106. package/dist/tools/generated/drive.js +133 -124
  107. package/dist/tools/generated/driveactivity.d.ts +2 -1
  108. package/dist/tools/generated/driveactivity.js +3 -3
  109. package/dist/tools/generated/drivelabels.d.ts +2 -1
  110. package/dist/tools/generated/drivelabels.js +113 -85
  111. package/dist/tools/generated/forms.d.ts +2 -1
  112. package/dist/tools/generated/forms.js +14 -14
  113. package/dist/tools/generated/gmail.d.ts +2 -1
  114. package/dist/tools/generated/gmail.js +253 -226
  115. package/dist/tools/generated/groupsmigration.d.ts +2 -1
  116. package/dist/tools/generated/groupsmigration.js +4 -4
  117. package/dist/tools/generated/groupssettings.d.ts +2 -1
  118. package/dist/tools/generated/groupssettings.js +10 -10
  119. package/dist/tools/generated/index.js +2 -0
  120. package/dist/tools/generated/keep.d.ts +2 -1
  121. package/dist/tools/generated/keep.js +23 -22
  122. package/dist/tools/generated/licensing.d.ts +2 -1
  123. package/dist/tools/generated/licensing.js +54 -38
  124. package/dist/tools/generated/meet.d.ts +2 -1
  125. package/dist/tools/generated/meet.js +41 -41
  126. package/dist/tools/generated/method-map.d.ts +2 -0
  127. package/dist/tools/generated/method-map.js +894 -0
  128. package/dist/tools/generated/postmaster.d.ts +2 -1
  129. package/dist/tools/generated/postmaster.js +12 -12
  130. package/dist/tools/generated/reseller.d.ts +2 -1
  131. package/dist/tools/generated/reseller.js +69 -63
  132. package/dist/tools/generated/script.d.ts +2 -1
  133. package/dist/tools/generated/script.js +62 -52
  134. package/dist/tools/generated/searchconsole.d.ts +2 -1
  135. package/dist/tools/generated/searchconsole.js +8 -6
  136. package/dist/tools/generated/sheets.d.ts +2 -1
  137. package/dist/tools/generated/sheets.js +25 -24
  138. package/dist/tools/generated/tasks.d.ts +2 -1
  139. package/dist/tools/generated/tasks.js +15 -9
  140. package/dist/tools/generated/vault.d.ts +2 -1
  141. package/dist/tools/generated/vault.js +132 -123
  142. package/dist/tools/generated/workspaceevents.d.ts +2 -1
  143. package/dist/tools/generated/workspaceevents.js +48 -47
  144. package/dist/tools/gmail.d.ts +12 -2
  145. package/dist/tools/gmail.js +79 -43
  146. package/dist/tools/google-api.js +51 -21
  147. package/dist/tools/meet.d.ts +2 -1
  148. package/dist/tools/meet.js +17 -10
  149. package/dist/tools/searchconsole.d.ts +2 -1
  150. package/dist/tools/searchconsole.js +27 -23
  151. package/dist/tools/sheets.d.ts +2 -1
  152. package/dist/tools/sheets.js +72 -80
  153. package/dist/tools/slides.d.ts +2 -1
  154. package/dist/tools/slides.js +18 -14
  155. package/dist/tools/tasks.d.ts +2 -1
  156. package/dist/tools/tasks.js +35 -31
  157. package/dist/trim.d.ts +21 -0
  158. package/dist/trim.js +47 -0
  159. package/dist/usage-metrics.d.ts +161 -0
  160. package/dist/usage-metrics.js +609 -0
  161. package/dist/write-control.d.ts +27 -1
  162. package/dist/write-control.js +90 -11
  163. package/package.json +45 -19
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # mcp-google-multi
2
2
 
3
- The most complete **local Google Workspace MCP server**: Gmail, Drive, Calendar, Sheets, Docs, Slides, Forms, Contacts, Tasks, Chat, Meet, Classroom, Vault, Admin and more — **every OAuth-reachable Workspace API method** as a tool, across **multiple Google accounts** at once, from Claude Code or any MCP client.
3
+ The most complete **local Google Workspace MCP server**: Gmail, Drive, Calendar, Sheets, Docs, Slides, Forms, Contacts, Tasks, Chat, Meet, Analytics (GA4), Search Console, Classroom, Vault, Admin and more — **every OAuth-reachable API method** as a tool, across **multiple Google accounts** at once, from Claude Code or any MCP client.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/mcp-google-multi?label=npm&color=cb3837)](https://www.npmjs.com/package/mcp-google-multi)
6
6
 
7
- - 🧰 **Exhaustive** — 874 tools across 28 services + an escape hatch for anything else → [COVERAGE.md](./COVERAGE.md)
7
+ - 🧰 **Exhaustive** — 940 tools across 29 services, now including Google Analytics (GA4), + an escape hatch for anything else → [COVERAGE.md](./COVERAGE.md)
8
8
  - 🔑 **Multi-account** — drive any number of Google accounts by alias, or fan one call out across all of them
9
9
  - 🔒 **Private by design** — your own OAuth app, tokens encrypted at rest (AES-256-GCM), writes deny-by-default, no telemetry, no metering — it talks only to Google
10
- - 🌐 **Local or remote** — runs locally over stdio, or self-hosted over HTTP with its own built-in OAuth 2.1 server (Claude Code's `/mcp` login and the claude.ai connector, zero custom UI) → [remote setup](./docs/http-setup.md)
10
+ - 🌐 **Local or remote** — runs locally over stdio, or self-hosted over HTTP with its own built-in OAuth 2.1 server (Claude Code's `/mcp` login and the claude.ai connector, zero custom UI). Pull-and-up Docker Compose with optional automatic HTTPS → [remote setup](./docs/http-setup.md)
11
11
  - ✉️ **Built for real work** — send and read email in Markdown with attachments and one-call replies, an interactive setup wizard with a `doctor` self-check, and per-account scope profiles → [features tour](./docs/features.md)
12
12
 
13
13
  ## Quick setup
@@ -20,6 +20,8 @@ New to all this? It's written for someone who just installed Claude Code and has
20
20
  npm install -g mcp-google-multi
21
21
  ```
22
22
 
23
+ Claude Desktop user? You can skip npm entirely: download the `mcp-google-multi.mcpb` bundle from the [latest release](https://github.com/bakissation/mcp-google-multi/releases/latest), double-click it (or drag it into Claude Desktop → Settings → Extensions), and fill in the values from step 2 when prompted.
24
+
23
25
  2. **Make your Google key** (the one manual part, a few minutes, because Google has no way to script it). Follow the step-by-step [Google Cloud setup](./docs/google-cloud-setup.md), or just ask Claude Code: *"walk me through creating a Google OAuth Desktop client for mcp-google-multi."* You finish with two values, a **Client ID** and a **Client Secret**. It's free and private to you.
24
26
 
25
27
  3. **Put them in a file.** In the folder you'll run from, make a file named `.env` and paste this, filling in your values:
@@ -51,7 +53,7 @@ New to all this? It's written for someone who just installed Claude Code and has
51
53
 
52
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).
53
55
 
54
- **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)
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)
55
57
 
56
58
  ## Maintainer & credits
57
59
 
@@ -18,7 +18,10 @@ export interface AccountSet {
18
18
  defaultAccount?: string;
19
19
  defaultAccountSource?: 'env' | 'config';
20
20
  }
21
- export declare function resolveAccounts(env?: NodeJS.ProcessEnv, filePath?: string, onInvalid?: 'exit' | 'throw'): AccountSet;
21
+ export declare function resolveAccounts(env?: NodeJS.ProcessEnv, filePath?: string, onInvalid?: 'exit' | 'throw', opts?: {
22
+ tokenDir?: string;
23
+ }): AccountSet;
24
+ export declare function fileStamp(filePath: string, version: number): string;
22
25
  /** Live accessor: dispatch-time readers use this, never a captured snapshot. */
23
26
  export declare function getAccountSet(): AccountSet;
24
27
  /** Re-resolve after a config.json mutation and swap the live set. */
@@ -30,6 +33,11 @@ export declare function isAccountSetStale(): boolean;
30
33
  * Snapshot from the initial load; enums widen only when the registry is
31
34
  * rebuilt after a mutation (account_add, later slice). */
32
35
  export declare const ACCOUNTS: string[];
36
+ /** One message for a mistyped alias, shared by every account schema. `aliases`
37
+ * is the SCHEMA's own snapshot, not the live registry: those are the values the
38
+ * enum actually accepts, and naming a runtime-added alias as valid while
39
+ * rejecting it would be a lie. */
40
+ export declare function unknownAliasMessage(aliases: readonly string[], selectors?: boolean): string;
33
41
  /**
34
42
  * The `account` param schema, empty-registry-safe. A `z.enum` requires at least
35
43
  * one value, so a fresh install (zero aliases) would throw at schema-build time
@@ -40,6 +48,13 @@ export declare const ACCOUNTS: string[];
40
48
  export declare function accountAliasSchemaFor(aliases: readonly string[]): z.ZodType<string>;
41
49
  /** Shared, load-time snapshot used by every tool's `account` field. */
42
50
  export declare const accountAliasSchema: z.ZodType<string>;
51
+ /** Live account argument: validates at PARSE time against the accessor's
52
+ * CURRENT aliases, so an alias added mid-session (account_add, or a tenant
53
+ * alias link) is immediately valid on already-registered tools — a baked
54
+ * z.enum freezes the boot-time set until restart. An empty set stays
55
+ * permissive (same empty-safe rule as above); dispatch still resolves the
56
+ * alias against the live registry. */
57
+ export declare function accountArgLive(aliases: () => readonly string[]): z.ZodType<string>;
43
58
  /**
44
59
  * BR-4: the stdio/http SERVER never boots with an empty registry — a fresh user
45
60
  * bootstraps via env / `migrate-config` / `account import` / `auth` first. The
package/dist/accounts.js CHANGED
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import fs from 'node:fs';
3
3
  import { z } from 'zod';
4
4
  import { loadEnvFiles } from './env-load.js';
5
- import { CONFIG_VERSION, configDir, configFilePath, failStartup, loadConfigFile, mutateConfigFile } from './config-file.js';
5
+ import { ALIAS_RE, CONFIG_VERSION, configDir, configFilePath, failStartup, isReservedAlias, loadConfigFile, mutateConfigFile } from './config-file.js';
6
6
  import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
7
7
  const envLoad = loadEnvFiles();
8
8
  const defaultTokenDir = path.join(configDir(), 'tokens');
@@ -15,14 +15,14 @@ export function getTokenDir() {
15
15
  function parseCsv(value) {
16
16
  return (value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
17
17
  }
18
- function accountPaths(alias) {
18
+ function accountPaths(alias, dir = tokenDir) {
19
19
  return {
20
- tokenPath: path.join(tokenDir, alias, 'token.json'),
21
- encPath: path.join(tokenDir, `${alias}.enc`),
20
+ tokenPath: path.join(dir, alias, 'token.json'),
21
+ encPath: path.join(dir, `${alias}.enc`),
22
22
  };
23
23
  }
24
24
  /** v5 env parser, guards verbatim. Format: GOOGLE_ACCOUNTS="alias1:email1,alias2:email2". */
25
- function parseEnvAccounts(raw, adminAliases) {
25
+ function parseEnvAccounts(raw, adminAliases, dir = tokenDir) {
26
26
  const configs = {};
27
27
  const aliases = [];
28
28
  for (const entry of raw.split(',')) {
@@ -40,7 +40,7 @@ function parseEnvAccounts(raw, adminAliases) {
40
40
  }
41
41
  // Restrict alias to a safe charset so it can't escape `tokenDir` via path traversal
42
42
  // (e.g. "../../etc/passwd:foo@bar.com" in .env).
43
- if (!/^[a-zA-Z0-9_-]+$/.test(alias) || ['__proto__', 'constructor', 'prototype'].includes(alias)) {
43
+ if (!ALIAS_RE.test(alias) || isReservedAlias(alias)) {
44
44
  throw new Error(`Invalid alias "${alias}". Allowed characters: letters, digits, underscore, hyphen.`);
45
45
  }
46
46
  if (aliases.includes(alias)) {
@@ -49,7 +49,7 @@ function parseEnvAccounts(raw, adminAliases) {
49
49
  aliases.push(alias);
50
50
  configs[alias] = {
51
51
  email,
52
- ...accountPaths(alias),
52
+ ...accountPaths(alias, dir),
53
53
  admin: adminAliases.includes(alias) || undefined,
54
54
  source: 'env',
55
55
  };
@@ -74,7 +74,8 @@ function noAccountsMessage() {
74
74
  * empty behaves as unset, mirroring GOOGLE_ACCOUNTS semantics.
75
75
  */
76
76
  let warnedLegacy = false;
77
- export function resolveAccounts(env = process.env, filePath = configFilePath(), onInvalid = 'exit') {
77
+ export function resolveAccounts(env = process.env, filePath = configFilePath(), onInvalid = 'exit', opts = {}) {
78
+ const dir = opts.tokenDir ?? tokenDir;
78
79
  const adminEnv = parseCsv(env.GOOGLE_ADMIN_ACCOUNTS);
79
80
  const rawEnv = env.GOOGLE_ACCOUNTS;
80
81
  const fail = (slug, message) => {
@@ -102,7 +103,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
102
103
  }
103
104
  }
104
105
  if (rawEnv && rawEnv.trim() !== '') {
105
- const { aliases, configs } = parseEnvAccounts(rawEnv, adminEnv);
106
+ const { aliases, configs } = parseEnvAccounts(rawEnv, adminEnv, dir);
106
107
  materializeFirstRun(aliases, configs, filePath);
107
108
  const def = resolveDefaultAccount(env, null, aliases, fail);
108
109
  return {
@@ -167,7 +168,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
167
168
  aliases.push(alias);
168
169
  configs[alias] = {
169
170
  email: entry.email,
170
- ...accountPaths(alias),
171
+ ...accountPaths(alias, dir),
171
172
  scopeProfile: entry.scopeProfile,
172
173
  admin: adminEnv.length > 0 ? adminEnv.includes(alias) : entry.admin,
173
174
  source: 'config',
@@ -196,7 +197,7 @@ function resolveDefaultAccount(env, fromConfig, aliases, fail) {
196
197
  }
197
198
  return { defaultAccount: value, defaultAccountSource: fromEnv ? 'env' : 'config' };
198
199
  }
199
- function fileStamp(filePath, version) {
200
+ export function fileStamp(filePath, version) {
200
201
  try {
201
202
  return `${version}:${fs.statSync(filePath).mtimeMs}`;
202
203
  }
@@ -278,6 +279,15 @@ export function isAccountSetStale() {
278
279
  * Snapshot from the initial load; enums widen only when the registry is
279
280
  * rebuilt after a mutation (account_add, later slice). */
280
281
  export const ACCOUNTS = current.aliases;
282
+ /** One message for a mistyped alias, shared by every account schema. `aliases`
283
+ * is the SCHEMA's own snapshot, not the live registry: those are the values the
284
+ * enum actually accepts, and naming a runtime-added alias as valid while
285
+ * rejecting it would be a lie. */
286
+ export function unknownAliasMessage(aliases, selectors = false) {
287
+ const all = selectors ? '; "*" for all accounts' : '';
288
+ const csv = selectors && aliases.length > 1 ? `; or a CSV subset like "${aliases.slice(0, 2).join(',')}"` : '';
289
+ return `Unknown account alias. Valid: ${aliases.join(', ')}${all}${csv}. Run account_list if accounts changed since this server started.`;
290
+ }
281
291
  /**
282
292
  * The `account` param schema, empty-registry-safe. A `z.enum` requires at least
283
293
  * one value, so a fresh install (zero aliases) would throw at schema-build time
@@ -286,10 +296,26 @@ export const ACCOUNTS = current.aliases;
286
296
  * are no aliases: no alias exists to enumerate, the server refuses to boot empty
287
297
  * anyway, and dispatch validates the account against the live set. */
288
298
  export function accountAliasSchemaFor(aliases) {
289
- return aliases.length > 0 ? z.enum(aliases) : z.string();
299
+ return aliases.length > 0
300
+ ? z.enum(aliases, { error: () => unknownAliasMessage(aliases) })
301
+ : z.string();
290
302
  }
291
303
  /** Shared, load-time snapshot used by every tool's `account` field. */
292
304
  export const accountAliasSchema = accountAliasSchemaFor(ACCOUNTS);
305
+ /** Live account argument: validates at PARSE time against the accessor's
306
+ * CURRENT aliases, so an alias added mid-session (account_add, or a tenant
307
+ * alias link) is immediately valid on already-registered tools — a baked
308
+ * z.enum freezes the boot-time set until restart. An empty set stays
309
+ * permissive (same empty-safe rule as above); dispatch still resolves the
310
+ * alias against the live registry. */
311
+ export function accountArgLive(aliases) {
312
+ return z.string().superRefine((value, ctx) => {
313
+ const current = aliases();
314
+ if (current.length > 0 && !current.includes(value)) {
315
+ ctx.addIssue({ code: 'custom', message: unknownAliasMessage(current) });
316
+ }
317
+ });
318
+ }
293
319
  /**
294
320
  * BR-4: the stdio/http SERVER never boots with an empty registry — a fresh user
295
321
  * bootstraps via env / `migrate-config` / `account import` / `auth` first. The
package/dist/api-probe.js CHANGED
@@ -17,6 +17,9 @@ export const API_PROBES = [
17
17
  { service: 'chat', api: 'chat', url: 'https://chat.googleapis.com/v1/spaces?pageSize=1', scopePrefixes: [`${P}chat.`] },
18
18
  { service: 'meet', api: 'meet', url: 'https://meet.googleapis.com/v2/conferenceRecords?pageSize=1', scopePrefixes: [`${P}meetings.`] },
19
19
  { service: 'forms', api: 'forms', url: `https://forms.googleapis.com/v1/forms/${BOGUS_ID}`, scopePrefixes: [`${P}forms.`], notFoundMeansEnabled: true },
20
+ // Probes the Admin API only: the Data API has no no-arg read (every call
21
+ // needs a property id), so its enablement surfaces on first report instead.
22
+ { service: 'analytics', api: 'analyticsadmin', url: 'https://analyticsadmin.googleapis.com/v1beta/accountSummaries?pageSize=1', scopePrefixes: [`${P}analytics`] },
20
23
  ];
21
24
  export function planProbes(granted, probes = API_PROBES) {
22
25
  return probes.filter((p) => granted.some((s) => p.scopePrefixes.some((prefix) => s.startsWith(prefix))));
@@ -0,0 +1,76 @@
1
+ import type { Transport, JSONRPCMessage } from "@modelcontextprotocol/server";
2
+ import { type SiblingSpelling, type UnknownArgMode } from './arg-strict.js';
3
+ export declare function isSchemaValidationText(text: string): boolean;
4
+ export declare function argNormalizationEnabled(env?: NodeJS.ProcessEnv): boolean;
5
+ /** Declared scalar kind per schema key; drives value coercion on RENAMED keys
6
+ * only. Clients string-encode values for keys absent from the advertised
7
+ * schema, so a renamed key almost always arrives as a string — without
8
+ * coercion the rename would just move the -32602 from the key to the value. */
9
+ export type ArgKind = 'number' | 'boolean' | 'other';
10
+ export type ArgShape = ReadonlyMap<string, ArgKind>;
11
+ export declare function normalizeCallArguments(shape: ArgShape, args: Record<string, unknown>): {
12
+ args: Record<string, unknown>;
13
+ renamed: [string, string][];
14
+ };
15
+ export declare function normalizeMessage(msg: JSONRPCMessage, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void, onRename?: (tool: string, renames: number) => void): JSONRPCMessage;
16
+ export interface StrictArgOptions {
17
+ mode: UnknownArgMode;
18
+ /** full declared key list for a tool, in declaration order */
19
+ declaredFor: (tool: string) => readonly string[] | undefined;
20
+ /** sibling spellings of a concept elsewhere in the same service */
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;
26
+ onDrop?: (tool: string, resolvedKeys: string[]) => void;
27
+ }
28
+ export type ScreenOutcome = {
29
+ action: 'forward';
30
+ msg: JSONRPCMessage;
31
+ } | {
32
+ action: 'reject';
33
+ response: JSONRPCMessage;
34
+ };
35
+ /**
36
+ * Screen a normalized tools/call for undeclared keys. `warn` forwards exactly
37
+ * as before and only reports; `reject` answers with the taxonomy envelope and
38
+ * never reaches the handler, so nothing is sent to Google on a guess.
39
+ */
40
+ export declare function screenMessage(msg: JSONRPCMessage, opts: StrictArgOptions, log?: (line: string) => void): ScreenOutcome;
41
+ /** Wrap a server-side transport so tools/call argument keys are normalized
42
+ * before the SDK validates them. The Protocol assigns `onmessage` during
43
+ * connect(); the interceptor lives in that setter, so the wrapper works
44
+ * identically for stdio and (per-request, stateless) HTTP transports. */
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 {};
@@ -0,0 +1,314 @@
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: /;
19
+ // Wire-level tools/call argument normalization. Clients (LLMs) recurringly
20
+ // snake_case a camelCase parameter (thread_id for threadId) and burn a retry
21
+ // on the -32602. A schema-level fix is off the table: the SDK advertises an
22
+ // EMPTY input schema for any non-object wrapper (pipe/preprocess), so the
23
+ // only seam that keeps tools/list intact is the JSON-RPC message itself —
24
+ // which is versioned MCP spec, stabler than any SDK internal. The rename is
25
+ // provably lossless: it fires only when the sent key is NOT in the tool's
26
+ // schema, its camelCase twin IS, and that twin was not also sent.
27
+ export function argNormalizationEnabled(env = process.env) {
28
+ return !/^(0|false|off|no)$/i.test((env.GOOGLE_ARG_NORMALIZE ?? '').trim());
29
+ }
30
+ const snakeToCamel = (key) => key.replace(/_([a-z0-9])/g, (_m, c) => c.toUpperCase());
31
+ function coerceRenamedValue(value, kind) {
32
+ if (typeof value !== 'string')
33
+ return value;
34
+ const v = value.trim();
35
+ if (kind === 'number' && /^-?\d+(\.\d+)?$/.test(v))
36
+ return Number(v);
37
+ if (kind === 'boolean' && /^(true|false)$/i.test(v))
38
+ return v.toLowerCase() === 'true';
39
+ return value;
40
+ }
41
+ export function normalizeCallArguments(shape, args) {
42
+ const renamed = [];
43
+ let out;
44
+ for (const key of Object.keys(args)) {
45
+ if (shape.has(key) || !key.includes('_'))
46
+ continue;
47
+ const camel = snakeToCamel(key);
48
+ if (camel !== key && shape.has(camel) && !(camel in args)) {
49
+ out ??= { ...args };
50
+ out[camel] = coerceRenamedValue(out[key], shape.get(camel));
51
+ delete out[key];
52
+ renamed.push([key, camel]);
53
+ }
54
+ }
55
+ return { args: out ?? args, renamed };
56
+ }
57
+ export function normalizeMessage(msg, shapeFor, log = (l) => process.stderr.write(`${l}\n`), onRename) {
58
+ const m = msg;
59
+ if (m.method !== 'tools/call' || typeof m.params?.name !== 'string')
60
+ return msg;
61
+ const args = m.params.arguments;
62
+ if (!args || typeof args !== 'object' || Array.isArray(args))
63
+ return msg;
64
+ const shape = shapeFor(m.params.name);
65
+ if (!shape)
66
+ return msg;
67
+ const { args: normalized, renamed } = normalizeCallArguments(shape, args);
68
+ if (renamed.length === 0)
69
+ return msg;
70
+ // Key names only — argument VALUES never reach the log.
71
+ log(`[args] ${m.params.name}: ${renamed.map(([f, t]) => `${f} -> ${t}`).join(', ')}`);
72
+ try {
73
+ onRename?.(m.params.name, renamed.length);
74
+ }
75
+ catch { /* observers never break dispatch */ }
76
+ return {
77
+ ...msg,
78
+ params: { ...m.params, arguments: normalized },
79
+ };
80
+ }
81
+ /**
82
+ * Screen a normalized tools/call for undeclared keys. `warn` forwards exactly
83
+ * as before and only reports; `reject` answers with the taxonomy envelope and
84
+ * never reaches the handler, so nothing is sent to Google on a guess.
85
+ */
86
+ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}\n`)) {
87
+ if (opts.mode === 'off')
88
+ return { action: 'forward', msg };
89
+ const m = msg;
90
+ if (m.method !== 'tools/call' || typeof m.params?.name !== 'string')
91
+ return { action: 'forward', msg };
92
+ const args = m.params.arguments;
93
+ if (!args || typeof args !== 'object' || Array.isArray(args))
94
+ return { action: 'forward', msg };
95
+ const tool = m.params.name;
96
+ const declared = opts.declaredFor(tool);
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
+ }
114
+ const screened = screenArguments(tool, args, declared);
115
+ if (screened.unknown.length === 0 && screened.redundant.length === 0)
116
+ return { action: 'forward', msg };
117
+ const all = [...screened.unknown.map((u) => u.sent), ...screened.redundant];
118
+ // Key names only; argument VALUES never reach the log.
119
+ log(`[args] ${tool}: undeclared ${all.join(', ')}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
120
+ try {
121
+ // Only ever a DECLARED key or the literal placeholder, so the metrics
122
+ // closed-vocabulary rule holds: the caller's key is never persisted.
123
+ opts.onDrop?.(tool, screened.unknown.map((u) => u.suggestions[0] ?? '_unmatched'));
124
+ }
125
+ catch { /* observers never break dispatch */ }
126
+ if (opts.mode === 'warn' || screened.unknown.length === 0)
127
+ return { action: 'forward', msg };
128
+ // Nothing to answer (a malformed notification): dispatch as before.
129
+ if (m.id === undefined)
130
+ return { action: 'forward', msg };
131
+ const account = args.account;
132
+ const siblings = screened.unknown.some((u) => u.suggestions.length > 0)
133
+ ? []
134
+ : (opts.siblingsFor?.(tool, screened.unknown.map((u) => u.sent)) ?? []);
135
+ const envelope = unknownArgEnvelope(tool, screened.unknown, declared, typeof account === 'string' ? account : undefined, siblings);
136
+ return {
137
+ action: 'reject',
138
+ response: {
139
+ jsonrpc: '2.0',
140
+ id: m.id,
141
+ result: { content: [{ type: 'text', text: JSON.stringify(envelope) }], isError: true },
142
+ },
143
+ };
144
+ }
145
+ /** Wrap a server-side transport so tools/call argument keys are normalized
146
+ * before the SDK validates them. The Protocol assigns `onmessage` during
147
+ * connect(); the interceptor lives in that setter, so the wrapper works
148
+ * identically for stdio and (per-request, stateless) HTTP transports. */
149
+ export function withArgNormalization(transport, shapeFor, log, onRename, strict) {
150
+ const wrapper = {
151
+ start: () => transport.start(),
152
+ send: (message, options) => transport.send(message, options),
153
+ close: () => transport.close(),
154
+ };
155
+ Object.defineProperty(wrapper, 'onmessage', {
156
+ get: () => transport.onmessage,
157
+ set: (handler) => {
158
+ transport.onmessage = handler
159
+ ? (message, extra) => {
160
+ // Rename FIRST: a snake_case twin of a declared key is a fix, not
161
+ // an unknown argument, so it must never reach the screen.
162
+ const normalized = normalizeMessage(message, shapeFor, log, onRename);
163
+ if (!strict)
164
+ return handler(normalized, extra);
165
+ const outcome = screenMessage(normalized, strict, log);
166
+ if (outcome.action === 'forward')
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);
173
+ try {
174
+ // Answer on the INNER transport so the metrics tap still sees the
175
+ // frame and clears its pending id.
176
+ void Promise.resolve(transport.send(outcome.response)).catch(fallback);
177
+ }
178
+ catch {
179
+ fallback();
180
+ }
181
+ }
182
+ : undefined;
183
+ },
184
+ });
185
+ for (const prop of ['onclose', 'onerror']) {
186
+ Object.defineProperty(wrapper, prop, {
187
+ get: () => transport[prop],
188
+ set: (v) => {
189
+ transport[prop] = v;
190
+ },
191
+ });
192
+ }
193
+ Object.defineProperty(wrapper, 'sessionId', { get: () => transport.sessionId });
194
+ if (transport.setProtocolVersion) {
195
+ wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
196
+ }
197
+ // v2-only, called by Protocol.connect() before start(). A no-op today (both
198
+ // sides default to the same exported constant), but a proxy that silently
199
+ // eats a member the SDK calls is a bug waiting for the first caller that
200
+ // passes supportedProtocolVersions explicitly.
201
+ if (transport.setSupportedProtocolVersions) {
202
+ wrapper.setSupportedProtocolVersions = (v) => transport.setSupportedProtocolVersions(v);
203
+ }
204
+ return wrapper;
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
+ }
@@ -0,0 +1,54 @@
1
+ export type UnknownArgMode = 'reject' | 'warn' | 'off';
2
+ /**
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.
7
+ */
8
+ export declare function unknownArgMode(env?: NodeJS.ProcessEnv): UnknownArgMode;
9
+ /** Tools that legitimately accept open-ended top-level keys. Empty at 6.0.0:
10
+ * the escape hatch is NOT one, because its open-endedness lives in the VALUES
11
+ * of queryParams/body, never in its six fixed top-level keys. */
12
+ export declare const STRICT_EXEMPT_TOOLS: ReadonlySet<string>;
13
+ /** Metadata a client may legitimately attach. Measured against all registered
14
+ * tools: no declared key starts with `_` or contains `/`, so neither rule can
15
+ * shadow a real parameter. */
16
+ export declare function isExemptKey(key: string): boolean;
17
+ /**
18
+ * Rank declared keys for an unknown key. Tiers, best non-empty tier wins:
19
+ * 0 same key modulo case and separators
20
+ * 1 every token of the unknown key appears in the declared key
21
+ * 2 substring containment of the flattened forms
22
+ * 3 a genuine typo by edit distance
23
+ * Plain edit distance alone cannot carry this: parentid -> parentfolderid is
24
+ * distance 6, well past any sane threshold, which is why tier 1 exists.
25
+ */
26
+ export declare function suggestKeys(unknown: string, declared: readonly string[], limit?: number): string[];
27
+ export interface ScreenedKey {
28
+ /** the key the caller sent; for stderr and the agent-facing hint only */
29
+ sent: string;
30
+ /** best suggestions, always declared keys of the tool being called */
31
+ suggestions: string[];
32
+ }
33
+ export interface ScreenResult {
34
+ unknown: ScreenedKey[];
35
+ /** dropped exactly as before: the caller also sent the declared key */
36
+ redundant: string[];
37
+ }
38
+ /**
39
+ * Split a call's post-rename keys into declared, redundant-duplicate and
40
+ * genuinely unknown. `declared` is the tool's full declared key list.
41
+ */
42
+ export declare function screenArguments(tool: string, args: Record<string, unknown>, declared: readonly string[]): ScreenResult;
43
+ export interface SiblingSpelling {
44
+ key: string;
45
+ tools: string[];
46
+ }
47
+ /** The agent-facing envelope. Values are never included, only key names. */
48
+ export declare function unknownArgEnvelope(tool: string, unknown: ScreenedKey[], declared: readonly string[], account: string | undefined, siblings?: SiblingSpelling[]): {
49
+ error: string;
50
+ message: string;
51
+ hint: string;
52
+ retriable: boolean;
53
+ account?: string;
54
+ };