mcp-google-multi 6.0.0-alpha.3 → 6.0.0-alpha.30

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 (78) hide show
  1. package/README.md +9 -3
  2. package/dist/api-probe.js +3 -0
  3. package/dist/arg-normalize.d.ts +19 -0
  4. package/dist/arg-normalize.js +94 -0
  5. package/dist/auth.js +15 -72
  6. package/dist/client.js +3 -1
  7. package/dist/config-file.d.ts +1 -0
  8. package/dist/config-file.js +4 -0
  9. package/dist/discover.js +35 -14
  10. package/dist/discovery-client.d.ts +8 -1
  11. package/dist/discovery-client.js +67 -15
  12. package/dist/doctor.d.ts +15 -0
  13. package/dist/doctor.js +112 -16
  14. package/dist/env-load.d.ts +11 -0
  15. package/dist/env-load.js +23 -1
  16. package/dist/executor.js +6 -0
  17. package/dist/http-transport.d.ts +8 -0
  18. package/dist/http-transport.js +3 -1
  19. package/dist/index.js +77 -6
  20. package/dist/metrics-cli.d.ts +21 -0
  21. package/dist/metrics-cli.js +206 -0
  22. package/dist/metrics-tap.d.ts +3 -0
  23. package/dist/metrics-tap.js +75 -0
  24. package/dist/oauth-consent.d.ts +25 -10
  25. package/dist/oauth-consent.js +85 -39
  26. package/dist/outbound-allowlist.d.ts +34 -0
  27. package/dist/outbound-allowlist.js +108 -0
  28. package/dist/registry.d.ts +17 -1
  29. package/dist/registry.js +82 -5
  30. package/dist/scope-catalog.d.ts +1 -0
  31. package/dist/scope-catalog.js +17 -1
  32. package/dist/services.js +3 -1
  33. package/dist/tools/_coerce.d.ts +4 -0
  34. package/dist/tools/_coerce.js +10 -0
  35. package/dist/tools/_errors.js +69 -5
  36. package/dist/tools/_local-files.d.ts +3 -0
  37. package/dist/tools/_local-files.js +34 -0
  38. package/dist/tools/account-wizard.d.ts +10 -0
  39. package/dist/tools/account-wizard.js +52 -19
  40. package/dist/tools/analytics.d.ts +18 -0
  41. package/dist/tools/analytics.js +279 -0
  42. package/dist/tools/calendar.js +11 -0
  43. package/dist/tools/drive.d.ts +2 -1
  44. package/dist/tools/drive.js +99 -37
  45. package/dist/tools/generated/_shared.d.ts +6 -0
  46. package/dist/tools/generated/_shared.js +11 -1
  47. package/dist/tools/generated/admin.js +160 -29
  48. package/dist/tools/generated/analytics.d.ts +2 -0
  49. package/dist/tools/generated/analytics.js +981 -0
  50. package/dist/tools/generated/chat.js +39 -12
  51. package/dist/tools/generated/classroom.js +56 -14
  52. package/dist/tools/generated/cloudidentity.js +18 -10
  53. package/dist/tools/generated/cloudsearch.js +4 -3
  54. package/dist/tools/generated/contacts.js +13 -4
  55. package/dist/tools/generated/drive.js +15 -6
  56. package/dist/tools/generated/drivelabels.js +33 -5
  57. package/dist/tools/generated/forms.js +1 -1
  58. package/dist/tools/generated/gmail.js +39 -12
  59. package/dist/tools/generated/index.js +2 -0
  60. package/dist/tools/generated/keep.js +3 -2
  61. package/dist/tools/generated/licensing.js +20 -4
  62. package/dist/tools/generated/meet.js +1 -1
  63. package/dist/tools/generated/method-map.d.ts +2 -0
  64. package/dist/tools/generated/method-map.js +894 -0
  65. package/dist/tools/generated/reseller.js +8 -2
  66. package/dist/tools/generated/script.js +13 -3
  67. package/dist/tools/generated/searchconsole.js +4 -2
  68. package/dist/tools/generated/sheets.js +2 -1
  69. package/dist/tools/generated/tasks.js +7 -1
  70. package/dist/tools/generated/vault.js +18 -9
  71. package/dist/tools/generated/workspaceevents.js +3 -2
  72. package/dist/tools/gmail.d.ts +10 -1
  73. package/dist/tools/gmail.js +35 -8
  74. package/dist/tools/google-api.d.ts +4 -1
  75. package/dist/tools/google-api.js +67 -16
  76. package/dist/usage-metrics.d.ts +146 -0
  77. package/dist/usage-metrics.js +567 -0
  78. package/package.json +25 -17
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:
@@ -53,6 +55,10 @@ New to all this? It's written for someone who just installed Claude Code and has
53
55
 
54
56
  **Go deeper:** [Configuration reference](./docs/configuration.md) · [What's covered](./COVERAGE.md) · [Features tour](./docs/features.md) · [Remote / HTTP setup](./docs/http-setup.md) · [Secrets in a vault](./docs/secrets.md) · [Migrating to v6](./MIGRATION-v6.md) · [Security policy](./SECURITY.md) · [Roadmap](https://github.com/bakissation/mcp-google-multi/milestones)
55
57
 
58
+ ## Local usage metrics (off by default)
59
+
60
+ The server can keep anonymous, **local-only** usage aggregates for its operator: tool names, error classes, latency buckets. Never arguments, payloads, message content, accounts, or identities of any kind, and **zero network egress ever** — the data cannot leave your machine unless you copy files yourself. It is off until you set `GOOGLE_USAGE_METRICS=on`; when on, the boot log and `doctor` say so and name the source. Read your own data with `mcp-google-multi metrics report`. Details, file format, and the honest threat model: [docs/usage-metrics.md](./docs/usage-metrics.md).
61
+
56
62
  ## Maintainer & credits
57
63
 
58
64
  Built and maintained by **Abdelbaki Berkati** — [berkati.xyz](https://berkati.xyz) · [@bakissation](https://github.com/bakissation). [Read the case study →](https://berkati.xyz/case-studies/mcp-google-multi/)
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,19 @@
1
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
2
+ import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
3
+ export declare function argNormalizationEnabled(env?: NodeJS.ProcessEnv): boolean;
4
+ /** Declared scalar kind per schema key; drives value coercion on RENAMED keys
5
+ * only. Clients string-encode values for keys absent from the advertised
6
+ * schema, so a renamed key almost always arrives as a string — without
7
+ * coercion the rename would just move the -32602 from the key to the value. */
8
+ export type ArgKind = 'number' | 'boolean' | 'other';
9
+ export type ArgShape = ReadonlyMap<string, ArgKind>;
10
+ export declare function normalizeCallArguments(shape: ArgShape, args: Record<string, unknown>): {
11
+ args: Record<string, unknown>;
12
+ renamed: [string, string][];
13
+ };
14
+ export declare function normalizeMessage(msg: JSONRPCMessage, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void, onRename?: (tool: string, renames: number) => void): JSONRPCMessage;
15
+ /** Wrap a server-side transport so tools/call argument keys are normalized
16
+ * before the SDK validates them. The Protocol assigns `onmessage` during
17
+ * connect(); the interceptor lives in that setter, so the wrapper works
18
+ * identically for stdio and (per-request, stateless) HTTP transports. */
19
+ export declare function withArgNormalization(transport: Transport, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void, onRename?: (tool: string, renames: number) => void): Transport;
@@ -0,0 +1,94 @@
1
+ // Wire-level tools/call argument normalization. Clients (LLMs) recurringly
2
+ // snake_case a camelCase parameter (thread_id for threadId) and burn a retry
3
+ // on the -32602. A schema-level fix is off the table: SDK 1.x advertises an
4
+ // EMPTY input schema for any non-object wrapper (pipe/preprocess), so the
5
+ // only seam that keeps tools/list intact is the JSON-RPC message itself —
6
+ // which is versioned MCP spec, stabler than any SDK internal. The rename is
7
+ // provably lossless: it fires only when the sent key is NOT in the tool's
8
+ // schema, its camelCase twin IS, and that twin was not also sent.
9
+ export function argNormalizationEnabled(env = process.env) {
10
+ return !/^(0|false|off|no)$/i.test((env.GOOGLE_ARG_NORMALIZE ?? '').trim());
11
+ }
12
+ const snakeToCamel = (key) => key.replace(/_([a-z0-9])/g, (_m, c) => c.toUpperCase());
13
+ function coerceRenamedValue(value, kind) {
14
+ if (typeof value !== 'string')
15
+ return value;
16
+ const v = value.trim();
17
+ if (kind === 'number' && /^-?\d+(\.\d+)?$/.test(v))
18
+ return Number(v);
19
+ if (kind === 'boolean' && /^(true|false)$/i.test(v))
20
+ return v.toLowerCase() === 'true';
21
+ return value;
22
+ }
23
+ export function normalizeCallArguments(shape, args) {
24
+ const renamed = [];
25
+ let out;
26
+ for (const key of Object.keys(args)) {
27
+ if (shape.has(key) || !key.includes('_'))
28
+ continue;
29
+ const camel = snakeToCamel(key);
30
+ if (camel !== key && shape.has(camel) && !(camel in args)) {
31
+ out ??= { ...args };
32
+ out[camel] = coerceRenamedValue(out[key], shape.get(camel));
33
+ delete out[key];
34
+ renamed.push([key, camel]);
35
+ }
36
+ }
37
+ return { args: out ?? args, renamed };
38
+ }
39
+ export function normalizeMessage(msg, shapeFor, log = (l) => process.stderr.write(`${l}\n`), onRename) {
40
+ const m = msg;
41
+ if (m.method !== 'tools/call' || typeof m.params?.name !== 'string')
42
+ return msg;
43
+ const args = m.params.arguments;
44
+ if (!args || typeof args !== 'object' || Array.isArray(args))
45
+ return msg;
46
+ const shape = shapeFor(m.params.name);
47
+ if (!shape)
48
+ return msg;
49
+ const { args: normalized, renamed } = normalizeCallArguments(shape, args);
50
+ if (renamed.length === 0)
51
+ return msg;
52
+ // Key names only — argument VALUES never reach the log.
53
+ log(`[args] ${m.params.name}: ${renamed.map(([f, t]) => `${f} -> ${t}`).join(', ')}`);
54
+ try {
55
+ onRename?.(m.params.name, renamed.length);
56
+ }
57
+ catch { /* observers never break dispatch */ }
58
+ return {
59
+ ...msg,
60
+ params: { ...m.params, arguments: normalized },
61
+ };
62
+ }
63
+ /** Wrap a server-side transport so tools/call argument keys are normalized
64
+ * before the SDK validates them. The Protocol assigns `onmessage` during
65
+ * connect(); the interceptor lives in that setter, so the wrapper works
66
+ * identically for stdio and (per-request, stateless) HTTP transports. */
67
+ export function withArgNormalization(transport, shapeFor, log, onRename) {
68
+ const wrapper = {
69
+ start: () => transport.start(),
70
+ send: (message, options) => transport.send(message, options),
71
+ close: () => transport.close(),
72
+ };
73
+ Object.defineProperty(wrapper, 'onmessage', {
74
+ get: () => transport.onmessage,
75
+ set: (handler) => {
76
+ transport.onmessage = handler
77
+ ? (message, extra) => handler(normalizeMessage(message, shapeFor, log, onRename), extra)
78
+ : undefined;
79
+ },
80
+ });
81
+ for (const prop of ['onclose', 'onerror']) {
82
+ Object.defineProperty(wrapper, prop, {
83
+ get: () => transport[prop],
84
+ set: (v) => {
85
+ transport[prop] = v;
86
+ },
87
+ });
88
+ }
89
+ Object.defineProperty(wrapper, 'sessionId', { get: () => transport.sessionId });
90
+ if (transport.setProtocolVersion) {
91
+ wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
92
+ }
93
+ return wrapper;
94
+ }
package/dist/auth.js CHANGED
@@ -1,12 +1,10 @@
1
- import { OAuth2Client } from 'googleapis-common';
2
- import http from 'node:http';
3
- import { URL } from 'node:url';
4
1
  import { randomBytes } from 'node:crypto';
5
2
  import { openUrl } from './open-url.js';
6
3
  import { ACCOUNTS, getAccountSet } from './accounts.js';
7
4
  import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
8
5
  import { resolveMasterKey } from './master-key.js';
9
6
  import { writeToken } from './token-store.js';
7
+ import { buildConsentClient, openLoopbackConsent, TESTING_MODE_WARNING } from './oauth-consent.js';
10
8
  // Personal (non-Workspace) accounts 403 on admin scopes; ADMIN_SCOPES stays per-account opt-in, never granted by default.
11
9
  export const BASE_SCOPES = [
12
10
  'https://www.googleapis.com/auth/gmail.modify',
@@ -134,7 +132,11 @@ export async function runAuthFlow(args) {
134
132
  // Auto-provisions on a fresh install (env > keychain > file > generate);
135
133
  // resolves eagerly so a provisioning failure surfaces before the browser opens.
136
134
  resolveMasterKey();
137
- const oauth2Client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost:4242/oauth2callback');
135
+ // Shared ephemeral-port loopback flow (oauth-consent.ts): listen first, then
136
+ // build the auth URL from the assigned redirect. CLI gets a patient timeout;
137
+ // errors propagate to main()'s fatal handler like every other CLI failure.
138
+ const loop = await openLoopbackConsent({ timeoutMs: 10 * 60_000 });
139
+ const oauth2Client = buildConsentClient(loop.redirect);
138
140
  // CSRF protection for the OAuth callback (RFC 6749 §10.12).
139
141
  const expectedState = randomBytes(32).toString('hex');
140
142
  const authorizeUrl = oauth2Client.generateAuthUrl({
@@ -149,72 +151,13 @@ export async function runAuthFlow(args) {
149
151
  if (getAdminAccounts().includes(alias)) {
150
152
  console.log(' ⚠ Admin scopes included — this account will be granted Workspace admin access.');
151
153
  }
152
- console.log(`Opening browser for authorization...`);
153
- return new Promise((resolve, reject) => {
154
- const server = http
155
- .createServer(async (req, res) => {
156
- try {
157
- if (req.url && req.url.startsWith('/oauth2callback')) {
158
- const qs = new URL(req.url, 'http://localhost:4242').searchParams;
159
- const error = qs.get('error');
160
- if (error) {
161
- res.writeHead(400, { 'Content-Type': 'text/plain' });
162
- res.end(`Authorization denied: ${error}`);
163
- server.close();
164
- server.closeAllConnections();
165
- reject(new Error(`Authorization denied: ${error}`));
166
- return;
167
- }
168
- const code = qs.get('code');
169
- if (!code) {
170
- res.writeHead(400, { 'Content-Type': 'text/plain' });
171
- res.end('No authorization code received.');
172
- server.close();
173
- server.closeAllConnections();
174
- reject(new Error('No authorization code received'));
175
- return;
176
- }
177
- const returnedState = qs.get('state');
178
- if (returnedState !== expectedState) {
179
- res.writeHead(400, { 'Content-Type': 'text/plain' });
180
- res.end('State mismatch — possible CSRF attempt. Aborting.');
181
- server.close();
182
- server.closeAllConnections();
183
- reject(new Error('OAuth state token mismatch'));
184
- return;
185
- }
186
- const { tokens } = await oauth2Client.getToken(code);
187
- writeToken(alias, tokens);
188
- res.writeHead(200, { 'Content-Type': 'text/html' });
189
- res.end('<h2>Authentication successful!</h2><p>You can close this tab.</p>');
190
- server.close();
191
- server.closeAllConnections();
192
- console.log(`Token saved (encrypted) for ${alias}.`);
193
- console.log('Next: authenticate your other aliases, then verify with: mcp-google-multi config check');
194
- resolve();
195
- }
196
- }
197
- catch (e) {
198
- res.writeHead(500, { 'Content-Type': 'text/plain' });
199
- res.end('Internal error during authentication.');
200
- server.close();
201
- server.closeAllConnections();
202
- reject(e);
203
- }
204
- })
205
- // Bind to loopback only — never expose the OAuth callback to the local network.
206
- .listen(4242, '127.0.0.1', () => {
207
- // Always print the URL first: the browser launch is best-effort and
208
- // silently does nothing on headless/SSH sessions.
209
- console.log(`Opening your browser to authorize "${alias}". If nothing opens, visit:\n${authorizeUrl}`);
210
- openUrl(authorizeUrl);
211
- });
212
- server.on('error', (err) => {
213
- if (err.code === 'EADDRINUSE') {
214
- console.error('Port 4242 is already in use. Close the process using it and retry.');
215
- process.exit(1);
216
- }
217
- reject(err);
218
- });
219
- });
154
+ // Always print the URL first: the browser launch is best-effort and
155
+ // silently does nothing on headless/SSH sessions.
156
+ console.log(`Opening your browser to authorize "${alias}". If nothing opens, visit:\n${authorizeUrl}`);
157
+ openUrl(authorizeUrl);
158
+ const tokens = await loop.finish(oauth2Client, expectedState);
159
+ writeToken(alias, tokens);
160
+ console.log(`Token saved (encrypted) for ${alias}.`);
161
+ console.log(TESTING_MODE_WARNING);
162
+ console.log('Next: authenticate your other aliases, then verify with: mcp-google-multi config check');
220
163
  }
package/dist/client.js CHANGED
@@ -14,7 +14,9 @@ export async function getClient(account) {
14
14
  throw new Error('GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set. ' +
15
15
  'Check that .env exists in the project root or pass them as env vars.');
16
16
  }
17
- const oauth2Client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost:4242/oauth2callback');
17
+ // Redirect URI is unused on the refresh-token grant; consent flows bind an
18
+ // ephemeral loopback port at auth time (oauth-consent.ts).
19
+ const oauth2Client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost/oauth2callback');
18
20
  const tokenData = readToken(account);
19
21
  if (!tokenData) {
20
22
  throw new Error(`No token found for account "${account}" (${config.email}). ${reauthHint(account)}`);
@@ -17,6 +17,7 @@ export interface ConfigFile {
17
17
  defaultAccount?: string;
18
18
  discovery?: 'lazy' | 'curated' | 'eager';
19
19
  toolsets?: string;
20
+ usageMetrics?: boolean;
20
21
  }
21
22
  export declare function failStartup(slug: string, message: string): never;
22
23
  export declare class ConfigFileError extends Error {
@@ -36,6 +36,10 @@ const configSchema = z.strictObject({
36
36
  defaultAccount: z.string().optional(),
37
37
  discovery: z.enum(['lazy', 'curated', 'eager']).optional(),
38
38
  toolsets: z.string().optional(),
39
+ // Local usage metrics (metrics-feature-spec): absent = off. Downgrade rule:
40
+ // a pre-6.0 build reading a config carrying this key fails E_CONFIG_INVALID
41
+ // (strictObject); remove the key first.
42
+ usageMetrics: z.boolean().optional(),
39
43
  });
40
44
  export function failStartup(slug, message) {
41
45
  process.stderr.write(`${slug}: ${message}\n`);
package/dist/discover.js CHANGED
@@ -1,8 +1,32 @@
1
1
  import { z } from 'zod';
2
2
  import { describePolicy } from './write-control.js';
3
+ // The description budget is the whole point of lazy mode: a full op list per
4
+ // service put lazy tools/list at ~11.5k tokens (RQ2), so descriptions carry a
5
+ // CAPPED vocabulary and the complete catalog stays in this tool's RESULT.
6
+ const CURATED_OPS_CAP = 10;
7
+ const GENERATED_GROUPS_CAP = 6;
3
8
  function opVocabulary(registry, service) {
4
- const ops = registry.catalog(service).map((o) => o.tool.startsWith(`${service}_`) ? o.tool.slice(service.length + 1) : o.tool);
5
- return [...new Set(ops)].join(', ');
9
+ const { curated, generated } = registry.opNames(service);
10
+ const parts = [];
11
+ if (curated.length > 0) {
12
+ const shown = curated.slice(0, CURATED_OPS_CAP);
13
+ const more = curated.length - shown.length;
14
+ parts.push(`${shown.join(', ')}${more > 0 ? ` +${more} more` : ''}`);
15
+ }
16
+ if (generated.length > 0) {
17
+ // The generated long tail compresses to its resource groups: denser and
18
+ // more selective than any truncated name list.
19
+ const counts = new Map();
20
+ for (const op of generated) {
21
+ const group = op.split('_')[0];
22
+ counts.set(group, (counts.get(group) ?? 0) + 1);
23
+ }
24
+ const groups = [...counts.entries()].sort((a, b) => b[1] - a[1]);
25
+ const shown = groups.slice(0, GENERATED_GROUPS_CAP).map(([g]) => g);
26
+ const more = groups.length - shown.length;
27
+ parts.push(`${generated.length} generated ops: ${shown.join(', ')}${more > 0 ? ` +${more} areas` : ''}`);
28
+ }
29
+ return parts.join('; ');
6
30
  }
7
31
  export function registerDiscoverTools(registry, policy) {
8
32
  const registerMeta = registry.registerMeta;
@@ -11,9 +35,8 @@ export function registerDiscoverTools(registry, policy) {
11
35
  // name/schema context budget after. stdio-only semantics — over stateless
12
36
  // HTTP the mode is forced curated and these are no-ops.
13
37
  registerMeta('discover_all', {
14
- description: 'Reveal ALL curated Google tools at once (instead of per-service discovery). ' +
15
- 'Use when starting substantial Google work so the shaped, high-quality tools are ' +
16
- 'directly callable; prefer them over google_api_call. Pair with discover_reset when done.',
38
+ description: 'Reveal ALL curated Google tools at once (instead of per-service discovery). Use when ' +
39
+ 'starting substantial Google work; prefer these over google_api_call. Pair with discover_reset.',
17
40
  inputSchema: {},
18
41
  _meta: { 'anthropic/alwaysLoad': true },
19
42
  }, async () => {
@@ -35,9 +58,8 @@ export function registerDiscoverTools(registry, policy) {
35
58
  };
36
59
  });
37
60
  registerMeta('discover_reset', {
38
- description: 'Collapse the tool surface back to the configured default (lean meta-tools-only under the ' +
39
- 'default lazy mode), reclaiming context budget after heavy Google work. All tools remain ' +
40
- 'callable by name after collapsing.',
61
+ description: 'Collapse the tool surface back to the configured default, reclaiming context budget ' +
62
+ 'after heavy Google work. All tools remain callable by name after collapsing.',
41
63
  inputSchema: {},
42
64
  _meta: { 'anthropic/alwaysLoad': true },
43
65
  }, async () => {
@@ -58,13 +80,12 @@ export function registerDiscoverTools(registry, policy) {
58
80
  });
59
81
  for (const service of registry.services()) {
60
82
  registerMeta(`${service}_discover`, {
61
- description: `List the available ${service} operations. ` +
62
- (registry.mode === 'lazy'
63
- ? `Operational ${service} tools are hidden until discovered — call this first, then call the tool you need by name. `
64
- : `Returns the ${service} catalog (and reveals any still-hidden ${service} tools). `) +
65
- `Operations: ${opVocabulary(registry, service)}.`,
83
+ description: (registry.mode === 'lazy'
84
+ ? `Discover ${service}: lists the catalog and reveals its hidden tools; call first, then call the tool by name. `
85
+ : `List the ${service} catalog (reveals any still-hidden ${service} tools). `) +
86
+ `Ops: ${opVocabulary(registry, service)}.`,
66
87
  inputSchema: {
67
- query: z.string().optional().describe('Keyword to filter the returned operations'),
88
+ query: z.string().optional().describe('Filter keyword'),
68
89
  },
69
90
  _meta: { 'anthropic/alwaysLoad': true },
70
91
  }, async ({ query }) => {
@@ -1,7 +1,14 @@
1
- export declare const WORKSPACE_APIS: Record<string, {
1
+ export declare const SUPPORTED_APIS: Record<string, {
2
2
  id: string;
3
3
  version: string;
4
4
  }>;
5
+ export declare const API_ALIASES: Record<string, string[]>;
6
+ /**
7
+ * Resolve an `api` argument to real SUPPORTED_APIS keys: exact match, then
8
+ * case/punctuation-normalized ("Search-Console" -> searchconsole), then the
9
+ * alias map. null = genuinely unknown.
10
+ */
11
+ export declare function resolveApiAliases(api: string): string[] | null;
5
12
  export interface DiscoveryParam {
6
13
  location: 'path' | 'query';
7
14
  required?: boolean;
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as os from 'node:os';
4
- export const WORKSPACE_APIS = {
4
+ export const SUPPORTED_APIS = {
5
5
  gmail: { id: 'gmail', version: 'v1' },
6
6
  drive: { id: 'drive', version: 'v3' },
7
7
  calendar: { id: 'calendar', version: 'v3' },
@@ -20,6 +20,8 @@ export const WORKSPACE_APIS = {
20
20
  admin_reports: { id: 'admin', version: 'reports_v1' },
21
21
  admin_datatransfer: { id: 'admin', version: 'datatransfer_v1' },
22
22
  groupssettings: { id: 'groupssettings', version: 'v1' },
23
+ analyticsadmin: { id: 'analyticsadmin', version: 'v1beta' },
24
+ analyticsdata: { id: 'analyticsdata', version: 'v1beta' },
23
25
  appsmarket: { id: 'appsmarket', version: 'v2' },
24
26
  classroom: { id: 'classroom', version: 'v1' },
25
27
  cloudidentity: { id: 'cloudidentity', version: 'v1' },
@@ -33,6 +35,38 @@ export const WORKSPACE_APIS = {
33
35
  vault: { id: 'vault', version: 'v1' },
34
36
  workspaceevents: { id: 'workspaceevents', version: 'v1' },
35
37
  };
38
+ // Names agents actually type for an API, mapped to real SUPPORTED_APIS keys.
39
+ // Motivated by observed escape-hatch misses ("analytics" is two Discovery
40
+ // APIs, the Admin SDK is three); keep entries plural-target only when the
41
+ // split is real.
42
+ export const API_ALIASES = {
43
+ analytics: ['analyticsadmin', 'analyticsdata'],
44
+ ga4: ['analyticsadmin', 'analyticsdata'],
45
+ googleanalytics: ['analyticsadmin', 'analyticsdata'],
46
+ admin: ['admin_directory', 'admin_reports', 'admin_datatransfer'],
47
+ adminsdk: ['admin_directory', 'admin_reports', 'admin_datatransfer'],
48
+ directory: ['admin_directory'],
49
+ webmasters: ['searchconsole'],
50
+ gsc: ['searchconsole'],
51
+ contacts: ['people'],
52
+ appsscript: ['script'],
53
+ appscript: ['script'],
54
+ gmailpostmastertools: ['postmaster'],
55
+ };
56
+ /**
57
+ * Resolve an `api` argument to real SUPPORTED_APIS keys: exact match, then
58
+ * case/punctuation-normalized ("Search-Console" -> searchconsole), then the
59
+ * alias map. null = genuinely unknown.
60
+ */
61
+ export function resolveApiAliases(api) {
62
+ if (SUPPORTED_APIS[api])
63
+ return [api];
64
+ const norm = api.trim().toLowerCase().replace(/[^a-z0-9]/g, '');
65
+ const direct = Object.keys(SUPPORTED_APIS).find((k) => k.replace(/[^a-z0-9]/g, '') === norm);
66
+ if (direct)
67
+ return [direct];
68
+ return API_ALIASES[norm] ?? null;
69
+ }
36
70
  const TTL_MS = 7 * 24 * 60 * 60 * 1000;
37
71
  const STALE_RETRY_MS = 5 * 60 * 1000;
38
72
  const FETCH_TIMEOUT_MS = 10_000;
@@ -96,9 +130,9 @@ export async function loadMethodIndex(api, deps = {}) {
96
130
  const cached = memoryCache.get(api);
97
131
  if (cached && now() < cached.refreshAfter)
98
132
  return cached.index;
99
- const spec = WORKSPACE_APIS[api];
133
+ const spec = SUPPORTED_APIS[api];
100
134
  if (!spec) {
101
- throw new Error(`Unknown api "${api}". Known: ${Object.keys(WORKSPACE_APIS).join(', ')}`);
135
+ throw new Error(`Unknown api "${api}". Known: ${Object.keys(SUPPORTED_APIS).join(', ')}`);
102
136
  }
103
137
  const fetchFn = deps.fetchFn ?? ((url) => fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }));
104
138
  const cacheDir = deps.cacheDir ?? discoveryCacheDir();
@@ -124,20 +158,34 @@ export async function loadMethodIndex(api, deps = {}) {
124
158
  if (cacheFresh())
125
159
  doc = readCache();
126
160
  if (!doc) {
127
- const url = `https://www.googleapis.com/discovery/v1/apis/${spec.id}/${spec.version}/rest`;
128
- try {
129
- const res = await fetchFn(url);
130
- if (!res.ok)
131
- throw new Error(`HTTP ${res.status}`);
132
- doc = (await res.json());
133
- fs.mkdirSync(cacheDir, { recursive: true });
134
- fs.writeFileSync(cacheFile, JSON.stringify(doc), { mode: 0o600 });
161
+ // Newer APIs (analyticsadmin/analyticsdata) are absent from the central
162
+ // discovery directory (404); each service's own $discovery endpoint is
163
+ // authoritative, so try both — same fallback as scripts/fetch-discovery.
164
+ const urls = [
165
+ `https://www.googleapis.com/discovery/v1/apis/${spec.id}/${spec.version}/rest`,
166
+ `https://${spec.id}.googleapis.com/$discovery/rest?version=${spec.version}`,
167
+ ];
168
+ let fetchError;
169
+ for (const url of urls) {
170
+ try {
171
+ const res = await fetchFn(url);
172
+ if (!res.ok)
173
+ throw new Error(`HTTP ${res.status}`);
174
+ doc = (await res.json());
175
+ fs.mkdirSync(cacheDir, { recursive: true });
176
+ fs.writeFileSync(cacheFile, JSON.stringify(doc), { mode: 0o600 });
177
+ break;
178
+ }
179
+ catch (err) {
180
+ fetchError = err;
181
+ doc = undefined;
182
+ }
135
183
  }
136
- catch (err) {
184
+ if (!doc) {
137
185
  doc = readCache();
138
186
  staleFallback = true;
139
187
  if (!doc) {
140
- throw new Error(`Could not fetch the Google API Discovery document for "${api}" and no local cache exists (${err.message}). Retry when online.`, { cause: err });
188
+ throw new Error(`Could not fetch the Google API Discovery document for "${api}" and no local cache exists (${fetchError?.message ?? 'fetch failed'}). Retry when online.`, { cause: fetchError });
141
189
  }
142
190
  }
143
191
  }
@@ -148,9 +196,13 @@ export async function loadMethodIndex(api, deps = {}) {
148
196
  export function clearDiscoveryMemoryCache() {
149
197
  memoryCache.clear();
150
198
  }
151
- const POST_READ_VERB = /^(get|list|search|query|lookup|count|batchGet|generateIds|export|download|inspect)/i;
199
+ // GA4-style report execution (runReport, batchRunPivotReports, runAccessReport)
200
+ // and check* predicates are POSTs purely for the request-body size — reads.
201
+ const POST_READ_VERB = /^(get|list|search|query|lookup|count|batchGet|generateIds|export|download|inspect|check|(batch)?run\w*report)/i;
152
202
  const POST_UPDATE_VERB = /^(untrash|undelete|restore|modify|move|set|sort|merge|unmerge|replace|resize|publish|resolve|update|patch|write|format)/i;
153
- const POST_DELETE_VERB = /^(batch)?(delete|remove|trash|clear|empty|obliterate|purge|revoke|wipeout)/i;
203
+ // archive sits with the deletes: in GA4 archiving a custom dimension/metric is
204
+ // permanent, so the most restrictive write class is the safe classification.
205
+ const POST_DELETE_VERB = /^(batch)?(delete|remove|trash|clear|empty|obliterate|purge|revoke|wipeout|archive)/i;
154
206
  export function cudFromMethod(method) {
155
207
  switch (method.httpMethod) {
156
208
  case 'GET':
package/dist/doctor.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { ToolRegistry } from './registry.js';
2
2
  import { getAccountSet } from './accounts.js';
3
3
  import { type AccountHealth } from './tools/accounts-tool.js';
4
4
  import { peekMasterKeyProvenance } from './master-key.js';
5
+ import { type HttpConfig } from './http-config.js';
5
6
  export type Verdict = 'ok' | 'warn' | 'fail' | 'unknown';
6
7
  export interface DiagnosticSection {
7
8
  id: number;
@@ -38,9 +39,23 @@ export interface DiagnosticsDeps {
38
39
  /** Optional live section-6 probe; when absent the section reports `unknown`
39
40
  * (spec: a section that cannot run is unknown, not FAIL). */
40
41
  probeApi?: (alias: string) => Promise<ApiProbeResult[]>;
42
+ /** Optional live section-7 endpoint probe (PRM/AS-metadata self-fetch);
43
+ * when absent, section 7 stays on its offline config checks. */
44
+ probeHttp?: (cfg: HttpConfig) => Promise<HttpProbeResult>;
45
+ }
46
+ /** Live §7 probe outcome. `unreachable` = connection-level failure (server not
47
+ * running), reported as `unknown` rather than FAIL; `problem` = a real
48
+ * metadata fault at a reachable server. */
49
+ export interface HttpProbeResult {
50
+ ok: boolean;
51
+ unreachable?: boolean;
52
+ problem?: string;
41
53
  }
42
54
  /** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
43
55
  export declare function apiEnableLink(api: string): string;
56
+ /** One line, state AND source, so the people being measured can see both
57
+ * here and in `diagnose` (metrics spec section 2). Read-only. */
58
+ export declare function usageMetricsStatusLine(env: Record<string, string | undefined>): string;
44
59
  /** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
45
60
  export declare function overallVerdict(sections: DiagnosticSection[]): Verdict;
46
61
  export declare function runDiagnostics(deps?: DiagnosticsDeps): Promise<DiagnosticsReport>;