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,163 @@
1
+ // Unknown-argument screening (backlog item 14). zod strips undeclared keys
2
+ // before a handler runs, so a misremembered argument name used to produce a
3
+ // SUCCESS response with wrong behavior: `parentId` on drive_create_folder was
4
+ // dropped and the folder landed in My Drive root. Field-reported, then
5
+ // reproduced: the typo, the correct name and a wholly invented key all
6
+ // returned byte-identical responses.
7
+ //
8
+ // This module is pure and holds no registry reference. It never rewrites a key
9
+ // and never touches a VALUE: it only decides that a key is undeclared, and
10
+ // suggests what the caller probably meant. Rewriting would risk sending a real
11
+ // value to Google on a guess, which is strictly worse than refusing.
12
+ import { editDistance } from './scope-catalog.js';
13
+ /**
14
+ * `GOOGLE_ARG_UNKNOWN`: reject | warn | off. A typo in the VALUE still falls
15
+ * back to `warn`, not to the default: an operator who misspells the setting
16
+ * has said nothing about which behavior they want, and guessing the strict
17
+ * one would turn a config typo into failing tool calls.
18
+ */
19
+ export function unknownArgMode(env = process.env) {
20
+ const raw = (env.GOOGLE_ARG_UNKNOWN ?? '').trim().toLowerCase();
21
+ if (raw === '')
22
+ return DEFAULT_MODE;
23
+ if (raw === 'reject' || raw === 'warn' || raw === 'off')
24
+ return raw;
25
+ process.stderr.write(`GOOGLE_ARG_UNKNOWN="${raw}" is not valid (reject | warn | off); using warn\n`);
26
+ return 'warn';
27
+ }
28
+ // The staged rollout is over: `warn` shipped first as pure observability, and
29
+ // this is the flip it was staging for. An undeclared argument is dropped by
30
+ // zod before the handler runs, so `warn` means the CLIENT sees a confident
31
+ // success for a call the server did not perform: drive_list with a misspelled
32
+ // folder key returned the My Drive root, byte-identical to no argument at all.
33
+ // A wrong answer that reads as right is the failure mode agents recover from
34
+ // worst, so the default refuses the call and says what it probably meant.
35
+ const DEFAULT_MODE = 'reject';
36
+ /** Tools that legitimately accept open-ended top-level keys. Empty at 6.0.0:
37
+ * the escape hatch is NOT one, because its open-endedness lives in the VALUES
38
+ * of queryParams/body, never in its six fixed top-level keys. */
39
+ export const STRICT_EXEMPT_TOOLS = new Set();
40
+ /** Keys a CLIENT adds on its own, not keys the model chose. `random_string`
41
+ * is the long-standing probe some clients send to a tool they read as taking
42
+ * no arguments; the others are call-plumbing that bridges and proxies have
43
+ * been observed to fold into `arguments` instead of `params._meta`, where the
44
+ * spec puts them. Rejecting these would fail a call the model got right.
45
+ * Measured against the full 978-tool surface: none is a declared key. */
46
+ const CLIENT_ARTIFACT_KEYS = new Set([
47
+ 'random_string',
48
+ 'toolCallId',
49
+ 'tool_call_id',
50
+ 'tool_call_description',
51
+ ]);
52
+ /** Metadata a client may legitimately attach. Measured against all registered
53
+ * tools: no declared key starts with `_` or contains `/`, so neither rule can
54
+ * shadow a real parameter. */
55
+ export function isExemptKey(key) {
56
+ return key.startsWith('_') || key.includes('/') || CLIENT_ARTIFACT_KEYS.has(key);
57
+ }
58
+ /** Generic words that must never be offered as a suggestion on containment
59
+ * alone; they match far too much to be useful. */
60
+ const GENERIC = new Set(['id', 'name', 'title', 'body', 'parent', 'text', 'content', 'type', 'value', 'key', 'data']);
61
+ const flatten = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
62
+ function tokens(s) {
63
+ return s
64
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
65
+ .split(/[\s_\-.]+/)
66
+ .map((t) => t.toLowerCase())
67
+ .filter(Boolean);
68
+ }
69
+ /**
70
+ * Rank declared keys for an unknown key. Tiers, best non-empty tier wins:
71
+ * 0 same key modulo case and separators
72
+ * 1 every token of the unknown key appears in the declared key
73
+ * 2 substring containment of the flattened forms
74
+ * 3 a genuine typo by edit distance
75
+ * Plain edit distance alone cannot carry this: parentid -> parentfolderid is
76
+ * distance 6, well past any sane threshold, which is why tier 1 exists.
77
+ */
78
+ export function suggestKeys(unknown, declared, limit = 2) {
79
+ const uFlat = flatten(unknown);
80
+ const uTok = tokens(unknown);
81
+ const tiers = [[], [], [], []];
82
+ for (const key of declared) {
83
+ const kFlat = flatten(key);
84
+ const kTok = tokens(key);
85
+ if (kFlat === uFlat) {
86
+ tiers[0].push({ key, rank: 0 });
87
+ continue;
88
+ }
89
+ if (uTok.length >= 2 && uTok.every((t) => kTok.includes(t))) {
90
+ tiers[1].push({ key, rank: Math.abs(kTok.length - uTok.length) });
91
+ continue;
92
+ }
93
+ if (uFlat.length >= 4 && !GENERIC.has(kFlat) && (kFlat.includes(uFlat) || uFlat.includes(kFlat))) {
94
+ tiers[2].push({ key, rank: Math.abs(kFlat.length - uFlat.length) });
95
+ continue;
96
+ }
97
+ const d = editDistance(uFlat, kFlat);
98
+ if (d <= Math.max(1, Math.floor(uFlat.length / 4)))
99
+ tiers[3].push({ key, rank: d });
100
+ }
101
+ const tier = tiers.find((t) => t.length > 0);
102
+ if (!tier)
103
+ return [];
104
+ // Stable: declaration order breaks rank ties.
105
+ return tier
106
+ .map((e, i) => ({ ...e, i }))
107
+ .sort((a, b) => a.rank - b.rank || a.i - b.i)
108
+ .slice(0, limit)
109
+ .map((e) => e.key);
110
+ }
111
+ /**
112
+ * Split a call's post-rename keys into declared, redundant-duplicate and
113
+ * genuinely unknown. `declared` is the tool's full declared key list.
114
+ */
115
+ export function screenArguments(tool, args, declared) {
116
+ const out = { unknown: [], redundant: [] };
117
+ if (STRICT_EXEMPT_TOOLS.has(tool) || declared.length === 0)
118
+ return out;
119
+ const declaredSet = new Set(declared);
120
+ for (const key of Object.keys(args)) {
121
+ if (declaredSet.has(key) || isExemptKey(key))
122
+ continue;
123
+ const suggestions = suggestKeys(key, declared);
124
+ // The caller sent both spellings, so the declared one already won and the
125
+ // outcome is what it has always been. Never fail a call that works today.
126
+ if (suggestions.some((s) => s in args)) {
127
+ out.redundant.push(key);
128
+ continue;
129
+ }
130
+ out.unknown.push({ sent: key, suggestions });
131
+ }
132
+ return out;
133
+ }
134
+ /** The agent-facing envelope. Values are never included, only key names. */
135
+ export function unknownArgEnvelope(tool, unknown, declared, account, siblings = []) {
136
+ const names = unknown.map((u) => `"${u.sent}"`).join(', ');
137
+ const accepts = `This tool accepts: ${declared.join(', ')}.`;
138
+ const suggested = unknown.flatMap((u) => u.suggestions);
139
+ let hint;
140
+ if (suggested.length > 0) {
141
+ const did = unknown
142
+ .filter((u) => u.suggestions.length > 0)
143
+ .map((u) => u.suggestions.map((s) => `"${s}"`).join(' or '))
144
+ .join(', ');
145
+ hint = `Did you mean ${did}? ${accepts}`;
146
+ }
147
+ else if (siblings.length > 0) {
148
+ const spelled = siblings
149
+ .map((s) => `"${s.key}" (${s.tools.slice(0, 3).join(', ')})`)
150
+ .join(' and ');
151
+ hint = `${accepts} Other tools in this service spell a similar argument ${spelled}.`;
152
+ }
153
+ else {
154
+ hint = accepts;
155
+ }
156
+ return {
157
+ error: 'unknown_argument',
158
+ message: `${tool} does not accept ${names}. Nothing was sent to Google.`,
159
+ hint,
160
+ retriable: false,
161
+ ...(account !== undefined ? { account } : {}),
162
+ };
163
+ }
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
@@ -2,22 +2,32 @@ import { OAuth2Client } from 'googleapis-common';
2
2
  import { getAccountSet, refreshAccountSetIfStale } from './accounts.js';
3
3
  import { readToken, updateToken } from './token-store.js';
4
4
  import { reauthHint } from './reauth-hint.js';
5
+ /** A local precondition failure, carrying a code the error mapper classifies
6
+ * on. Without it these land on the generic floor and read as Google errors. */
7
+ function tagged(message, code) {
8
+ return Object.assign(new Error(message), { code });
9
+ }
5
10
  export async function getClient(account) {
6
11
  // BR-7: lazy cross-process reload — one stat per dispatch, no watcher;
7
12
  // reload failures keep the last-good registry, never kill the server.
8
13
  refreshAccountSetIfStale();
9
14
  const config = getAccountSet().configs[account];
10
15
  if (!config) {
11
- throw new Error(`Unknown account "${account}". Valid aliases: ${getAccountSet().aliases.join(', ')}`);
16
+ // Tagged so mapGoogleError can classify it. Untagged, a caller's typo in
17
+ // an alias came back as `upstream_error` with "Unclassified error", i.e.
18
+ // the server blaming Google for a local argument mistake.
19
+ throw tagged(`Unknown account "${account}". Valid aliases: ${getAccountSet().aliases.join(', ')}`, 'E_UNKNOWN_ACCOUNT');
12
20
  }
13
21
  if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
14
- throw new Error('GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set. ' +
15
- 'Check that .env exists in the project root or pass them as env vars.');
22
+ throw tagged('GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set. ' +
23
+ 'Check that .env exists in the project root or pass them as env vars.', 'E_NO_OAUTH_CLIENT');
16
24
  }
17
- const oauth2Client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost:4242/oauth2callback');
25
+ // Redirect URI is unused on the refresh-token grant; consent flows bind an
26
+ // ephemeral loopback port at auth time (oauth-consent.ts).
27
+ const oauth2Client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost/oauth2callback');
18
28
  const tokenData = readToken(account);
19
29
  if (!tokenData) {
20
- throw new Error(`No token found for account "${account}" (${config.email}). ${reauthHint(account)}`);
30
+ throw tagged(`No token found for account "${account}" (${config.email}). ${reauthHint(account)}`, 'E_NO_TOKEN');
21
31
  }
22
32
  oauth2Client.setCredentials(tokenData);
23
33
  oauth2Client.on('tokens', (tokens) => {
@@ -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,24 @@ 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;
152
- 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;
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|suggest|(batch)?run\w*report)/i;
202
+ // `cancelWipe` must be matched here, BEFORE `cancel` reaches the delete list:
203
+ // it calls off a pending wipe, which is the opposite of one. Update is tested
204
+ // before delete for exactly that reason.
205
+ // State transitions on something that already exists: reversible, and none of
206
+ // them creates anything, so `create` was both wrong and the most permissive
207
+ // class available. `(batch)?` mirrors the delete list, without which every
208
+ // `batchUpdate*` POST fell through to `create`.
209
+ const POST_UPDATE_VERB = /^(cancelWipe|(batch)?(untrash|undelete|restore|modify|move|set|sort|merge|unmerge|replace|resize|publish|resolve|update|patch|write|format|change|close|reopen|enable|disable|hide|unhide|accept|approve|decline|reassign|reactivate|renew|mark|turn|suspend|activate|make|return|reclaim|complete))/i;
210
+ // archive sits with the deletes: in GA4 archiving a custom dimension/metric is
211
+ // permanent, so the most restrictive write class is the safe classification.
212
+ // So do the teardown verbs: `stop` and `cancel` tear down a push channel or a
213
+ // long-running operation, `wipe`/`invalidate`/`signOut` destroy device data,
214
+ // codes and sessions, `reset` discards a configuration, and `end` terminates a
215
+ // live conference. Each removes something that existed.
216
+ const POST_DELETE_VERB = /^(batch)?(delete|remove|trash|clear|empty|obliterate|purge|revoke|wipeout|archive|wipe|invalidate|signOut|stop|cancel|unregister|unreserve|reset|end)/i;
154
217
  export function cudFromMethod(method) {
155
218
  switch (method.httpMethod) {
156
219
  case 'GET':
@@ -181,7 +244,23 @@ export function expandPath(template, pathParams) {
181
244
  if (value === undefined) {
182
245
  throw new Error(`Missing required path parameter "${name}"`);
183
246
  }
184
- return plus ? String(value).split('/').map(encodeURIComponent).join('/') : encodeURIComponent(String(value));
247
+ // A blank segment collapses the path and Google routes the resulting
248
+ // trailing-slash URL to the COLLECTION: `sites.get` with siteUrl:"" came
249
+ // back as the whole site LIST, reported as success. Dot segments resolve
250
+ // at URL-parse time, so `../v1/contactGroups` retargets the call at a
251
+ // different endpoint and returns ITS response as success. Both are
252
+ // silent-wrong, so refuse locally rather than send a different request.
253
+ // `{+param}` keeps its slashes, so every segment has to be checked.
254
+ const segments = plus ? String(value).split('/') : [String(value)];
255
+ for (const segment of segments) {
256
+ if (segment.trim() === '') {
257
+ throw new Error(`Path parameter "${name}" is empty. An empty value addresses the collection, not one resource, so nothing was sent to Google.`);
258
+ }
259
+ if (segment === '.' || segment === '..') {
260
+ throw new Error(`Path parameter "${name}" contains a "${segment}" path segment, which would retarget the request at a different endpoint. Nothing was sent to Google.`);
261
+ }
262
+ }
263
+ return segments.map(encodeURIComponent).join('/');
185
264
  });
186
265
  }
187
266
  export function searchMethods(index, query, limit = 10) {
package/dist/doctor.d.ts CHANGED
@@ -53,6 +53,9 @@ export interface HttpProbeResult {
53
53
  }
54
54
  /** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
55
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;
56
59
  /** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
57
60
  export declare function overallVerdict(sections: DiagnosticSection[]): Verdict;
58
61
  export declare function runDiagnostics(deps?: DiagnosticsDeps): Promise<DiagnosticsReport>;
@@ -64,7 +67,12 @@ export declare function renderReport(report: DiagnosticsReport, version: string)
64
67
  /** Exit non-zero when any section FAILs; with strict, WARN counts too. */
65
68
  export declare function exitCodeFor(report: DiagnosticsReport, strict: boolean): number;
66
69
  /** Agent-callable structured health report (read-only). Mirrors `doctor`'s
67
- * engine; NOT alwaysLoad (the agent asks for it when diagnosing). */
70
+ * engine. Registered as a META tool, like `account_list`: it introspects this
71
+ * server rather than Google data, and as a normal tool it became a service of
72
+ * its own with no `{service}_discover` to reveal it (discover tools are built
73
+ * from already-registered services, and this runs after that), so it was
74
+ * advertised only once something else expanded the surface. The README sends
75
+ * people here when they are stuck, so it has to be findable. */
68
76
  export declare function registerDiagnoseTool(registry: ToolRegistry): void;
69
77
  export interface ResetOptions {
70
78
  account?: string;