mcp-google-multi 6.0.0-beta.1 → 6.0.0-beta.3

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 (182) hide show
  1. package/README.md +5 -3
  2. package/dist/accounts.d.ts +22 -1
  3. package/dist/accounts.js +64 -16
  4. package/dist/api-probe.d.ts +5 -0
  5. package/dist/api-probe.js +23 -15
  6. package/dist/arg-normalize.d.ts +61 -4
  7. package/dist/arg-normalize.js +229 -4
  8. package/dist/arg-strict.d.ts +61 -0
  9. package/dist/arg-strict.js +193 -0
  10. package/dist/auth.d.ts +4 -3
  11. package/dist/auth.js +26 -85
  12. package/dist/boot-gates.d.ts +26 -0
  13. package/dist/boot-gates.js +38 -0
  14. package/dist/cimd-cache.d.ts +38 -0
  15. package/dist/cimd-cache.js +117 -0
  16. package/dist/client.d.ts +32 -2
  17. package/dist/client.js +0 -0
  18. package/dist/compose.d.ts +11 -0
  19. package/dist/compose.js +109 -0
  20. package/dist/config-file.d.ts +14 -0
  21. package/dist/config-file.js +41 -1
  22. package/dist/discover.js +38 -15
  23. package/dist/discovery-client.d.ts +11 -1
  24. package/dist/discovery-client.js +120 -18
  25. package/dist/doctor.d.ts +15 -3
  26. package/dist/doctor.js +151 -27
  27. package/dist/env-load.d.ts +11 -0
  28. package/dist/env-load.js +23 -1
  29. package/dist/executor.d.ts +3 -0
  30. package/dist/executor.js +16 -3
  31. package/dist/fanout.d.ts +9 -1
  32. package/dist/fanout.js +83 -14
  33. package/dist/fs-atomic.d.ts +11 -0
  34. package/dist/fs-atomic.js +219 -28
  35. package/dist/hint-provider.d.ts +129 -0
  36. package/dist/hint-provider.js +64 -0
  37. package/dist/http-transport.d.ts +73 -6
  38. package/dist/http-transport.js +350 -26
  39. package/dist/identity.d.ts +4 -1
  40. package/dist/identity.js +25 -6
  41. package/dist/index.js +142 -114
  42. package/dist/master-key.js +13 -5
  43. package/dist/mcp-token.d.ts +136 -19
  44. package/dist/mcp-token.js +569 -72
  45. package/dist/metrics-cli.d.ts +21 -0
  46. package/dist/metrics-cli.js +206 -0
  47. package/dist/metrics-tap.d.ts +12 -0
  48. package/dist/metrics-tap.js +95 -0
  49. package/dist/migrate-config.js +2 -2
  50. package/dist/oauth-as.d.ts +103 -3
  51. package/dist/oauth-as.js +374 -79
  52. package/dist/oauth-consent.d.ts +35 -10
  53. package/dist/oauth-consent.js +101 -39
  54. package/dist/outbound-allowlist.d.ts +34 -0
  55. package/dist/outbound-allowlist.js +111 -0
  56. package/dist/reauth-hint.d.ts +1 -1
  57. package/dist/reauth-hint.js +12 -8
  58. package/dist/registry-transfer.d.ts +5 -0
  59. package/dist/registry-transfer.js +65 -5
  60. package/dist/registry.d.ts +66 -2
  61. package/dist/registry.js +214 -18
  62. package/dist/scope-catalog.d.ts +11 -0
  63. package/dist/scope-catalog.js +39 -6
  64. package/dist/scope-observability.d.ts +8 -6
  65. package/dist/scope-observability.js +29 -13
  66. package/dist/services.d.ts +17 -4
  67. package/dist/services.js +77 -6
  68. package/dist/setup-prompt.d.ts +1 -1
  69. package/dist/setup-prompt.js +1 -1
  70. package/dist/ssrf-guard.d.ts +23 -9
  71. package/dist/ssrf-guard.js +101 -62
  72. package/dist/tenant-purge.d.ts +26 -0
  73. package/dist/tenant-purge.js +67 -0
  74. package/dist/token-store.d.ts +20 -2
  75. package/dist/token-store.js +0 -0
  76. package/dist/tools/_coerce.d.ts +5 -0
  77. package/dist/tools/_coerce.js +11 -0
  78. package/dist/tools/_errors.d.ts +34 -3
  79. package/dist/tools/_errors.js +346 -53
  80. package/dist/tools/_local-files.d.ts +9 -0
  81. package/dist/tools/_local-files.js +25 -2
  82. package/dist/tools/account-wizard.d.ts +34 -2
  83. package/dist/tools/account-wizard.js +223 -65
  84. package/dist/tools/accounts-tool.d.ts +7 -0
  85. package/dist/tools/accounts-tool.js +34 -18
  86. package/dist/tools/admin.d.ts +10 -1
  87. package/dist/tools/admin.js +21 -16
  88. package/dist/tools/analytics.d.ts +27 -0
  89. package/dist/tools/analytics.js +289 -0
  90. package/dist/tools/calendar.d.ts +9 -1
  91. package/dist/tools/calendar.js +93 -29
  92. package/dist/tools/chat.d.ts +10 -1
  93. package/dist/tools/chat.js +20 -12
  94. package/dist/tools/contacts.d.ts +2 -1
  95. package/dist/tools/contacts.js +74 -43
  96. package/dist/tools/docs.d.ts +2 -1
  97. package/dist/tools/docs.js +90 -84
  98. package/dist/tools/drive.d.ts +2 -1
  99. package/dist/tools/drive.js +197 -120
  100. package/dist/tools/forms.d.ts +10 -1
  101. package/dist/tools/forms.js +26 -19
  102. package/dist/tools/generated/_shared.d.ts +8 -1
  103. package/dist/tools/generated/_shared.js +18 -4
  104. package/dist/tools/generated/admin.d.ts +2 -1
  105. package/dist/tools/generated/admin.js +610 -479
  106. package/dist/tools/generated/analytics.d.ts +3 -0
  107. package/dist/tools/generated/analytics.js +981 -0
  108. package/dist/tools/generated/appsmarket.d.ts +2 -1
  109. package/dist/tools/generated/appsmarket.js +9 -9
  110. package/dist/tools/generated/calendar.d.ts +2 -1
  111. package/dist/tools/generated/calendar.js +83 -83
  112. package/dist/tools/generated/chat.d.ts +2 -1
  113. package/dist/tools/generated/chat.js +178 -151
  114. package/dist/tools/generated/classroom.d.ts +2 -1
  115. package/dist/tools/generated/classroom.js +467 -425
  116. package/dist/tools/generated/cloudidentity.d.ts +2 -1
  117. package/dist/tools/generated/cloudidentity.js +209 -201
  118. package/dist/tools/generated/cloudsearch.d.ts +2 -1
  119. package/dist/tools/generated/cloudsearch.js +139 -138
  120. package/dist/tools/generated/contacts.d.ts +2 -1
  121. package/dist/tools/generated/contacts.js +49 -40
  122. package/dist/tools/generated/drive.d.ts +2 -1
  123. package/dist/tools/generated/drive.js +133 -124
  124. package/dist/tools/generated/driveactivity.d.ts +2 -1
  125. package/dist/tools/generated/driveactivity.js +3 -3
  126. package/dist/tools/generated/drivelabels.d.ts +2 -1
  127. package/dist/tools/generated/drivelabels.js +113 -85
  128. package/dist/tools/generated/forms.d.ts +2 -1
  129. package/dist/tools/generated/forms.js +14 -14
  130. package/dist/tools/generated/gmail.d.ts +2 -1
  131. package/dist/tools/generated/gmail.js +253 -226
  132. package/dist/tools/generated/groupsmigration.d.ts +2 -1
  133. package/dist/tools/generated/groupsmigration.js +4 -4
  134. package/dist/tools/generated/groupssettings.d.ts +2 -1
  135. package/dist/tools/generated/groupssettings.js +10 -10
  136. package/dist/tools/generated/index.d.ts +2 -1
  137. package/dist/tools/generated/index.js +2 -0
  138. package/dist/tools/generated/keep.d.ts +2 -1
  139. package/dist/tools/generated/keep.js +23 -22
  140. package/dist/tools/generated/licensing.d.ts +2 -1
  141. package/dist/tools/generated/licensing.js +54 -38
  142. package/dist/tools/generated/meet.d.ts +2 -1
  143. package/dist/tools/generated/meet.js +41 -41
  144. package/dist/tools/generated/method-map.d.ts +2 -0
  145. package/dist/tools/generated/method-map.js +894 -0
  146. package/dist/tools/generated/postmaster.d.ts +2 -1
  147. package/dist/tools/generated/postmaster.js +12 -12
  148. package/dist/tools/generated/reseller.d.ts +2 -1
  149. package/dist/tools/generated/reseller.js +69 -63
  150. package/dist/tools/generated/script.d.ts +2 -1
  151. package/dist/tools/generated/script.js +62 -52
  152. package/dist/tools/generated/searchconsole.d.ts +2 -1
  153. package/dist/tools/generated/searchconsole.js +8 -6
  154. package/dist/tools/generated/sheets.d.ts +2 -1
  155. package/dist/tools/generated/sheets.js +25 -24
  156. package/dist/tools/generated/tasks.d.ts +2 -1
  157. package/dist/tools/generated/tasks.js +15 -9
  158. package/dist/tools/generated/vault.d.ts +2 -1
  159. package/dist/tools/generated/vault.js +132 -123
  160. package/dist/tools/generated/workspaceevents.d.ts +2 -1
  161. package/dist/tools/generated/workspaceevents.js +48 -47
  162. package/dist/tools/gmail.d.ts +12 -2
  163. package/dist/tools/gmail.js +95 -51
  164. package/dist/tools/google-api.d.ts +2 -3
  165. package/dist/tools/google-api.js +76 -33
  166. package/dist/tools/meet.d.ts +10 -1
  167. package/dist/tools/meet.js +18 -11
  168. package/dist/tools/searchconsole.d.ts +2 -1
  169. package/dist/tools/searchconsole.js +27 -23
  170. package/dist/tools/sheets.d.ts +2 -1
  171. package/dist/tools/sheets.js +72 -80
  172. package/dist/tools/slides.d.ts +10 -1
  173. package/dist/tools/slides.js +23 -16
  174. package/dist/tools/tasks.d.ts +2 -1
  175. package/dist/tools/tasks.js +35 -31
  176. package/dist/trim.d.ts +26 -0
  177. package/dist/trim.js +67 -0
  178. package/dist/usage-metrics.d.ts +178 -0
  179. package/dist/usage-metrics.js +621 -0
  180. package/dist/write-control.d.ts +27 -1
  181. package/dist/write-control.js +99 -12
  182. package/package.json +34 -3
package/dist/discover.js CHANGED
@@ -1,8 +1,33 @@
1
1
  import { z } from 'zod';
2
2
  import { describePolicy } from './write-control.js';
3
+ import { hints } from './hint-provider.js';
4
+ // The description budget is the whole point of lazy mode: a full op list per
5
+ // service put lazy tools/list at ~11.5k tokens (RQ2), so descriptions carry a
6
+ // CAPPED vocabulary and the complete catalog stays in this tool's RESULT.
7
+ const CURATED_OPS_CAP = 10;
8
+ const GENERATED_GROUPS_CAP = 6;
3
9
  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(', ');
10
+ const { curated, generated } = registry.opNames(service);
11
+ const parts = [];
12
+ if (curated.length > 0) {
13
+ const shown = curated.slice(0, CURATED_OPS_CAP);
14
+ const more = curated.length - shown.length;
15
+ parts.push(`${shown.join(', ')}${more > 0 ? ` +${more} more` : ''}`);
16
+ }
17
+ if (generated.length > 0) {
18
+ // The generated long tail compresses to its resource groups: denser and
19
+ // more selective than any truncated name list.
20
+ const counts = new Map();
21
+ for (const op of generated) {
22
+ const group = op.split('_')[0];
23
+ counts.set(group, (counts.get(group) ?? 0) + 1);
24
+ }
25
+ const groups = [...counts.entries()].sort((a, b) => b[1] - a[1]);
26
+ const shown = groups.slice(0, GENERATED_GROUPS_CAP).map(([g]) => g);
27
+ const more = groups.length - shown.length;
28
+ parts.push(`${generated.length} generated ops: ${shown.join(', ')}${more > 0 ? ` +${more} areas` : ''}`);
29
+ }
30
+ return parts.join('; ');
6
31
  }
7
32
  export function registerDiscoverTools(registry, policy) {
8
33
  const registerMeta = registry.registerMeta;
@@ -11,9 +36,8 @@ export function registerDiscoverTools(registry, policy) {
11
36
  // name/schema context budget after. stdio-only semantics — over stateless
12
37
  // HTTP the mode is forced curated and these are no-ops.
13
38
  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.',
39
+ description: 'Reveal ALL curated Google tools at once (instead of per-service discovery). Use when ' +
40
+ 'starting substantial Google work; prefer these over google_api_call. Pair with discover_reset.',
17
41
  inputSchema: {},
18
42
  _meta: { 'anthropic/alwaysLoad': true },
19
43
  }, async () => {
@@ -35,9 +59,8 @@ export function registerDiscoverTools(registry, policy) {
35
59
  };
36
60
  });
37
61
  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.',
62
+ description: 'Collapse the tool surface back to the configured default, reclaiming context budget ' +
63
+ 'after heavy Google work. All tools remain callable by name after collapsing.',
41
64
  inputSchema: {},
42
65
  _meta: { 'anthropic/alwaysLoad': true },
43
66
  }, async () => {
@@ -58,18 +81,18 @@ export function registerDiscoverTools(registry, policy) {
58
81
  });
59
82
  for (const service of registry.services()) {
60
83
  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)}.`,
84
+ description: (registry.mode === 'lazy'
85
+ ? `Discover ${service}: lists the catalog and reveals its hidden tools; call first, then call the tool by name. `
86
+ : `List the ${service} catalog (reveals any still-hidden ${service} tools). `) +
87
+ `Ops: ${opVocabulary(registry, service)}.`,
66
88
  inputSchema: {
67
- query: z.string().optional().describe('Keyword to filter the returned operations'),
89
+ query: z.string().optional().describe('Filter keyword'),
68
90
  },
69
91
  _meta: { 'anthropic/alwaysLoad': true },
70
92
  }, async ({ query }) => {
71
93
  const operations = registry.catalog(service, query);
72
94
  registry.reveal(service);
95
+ const rendered = hints()?.render({ kind: 'policy_summary', policy });
73
96
  return {
74
97
  content: [
75
98
  {
@@ -77,7 +100,7 @@ export function registerDiscoverTools(registry, policy) {
77
100
  text: JSON.stringify({
78
101
  service,
79
102
  operations,
80
- writeControl: describePolicy(policy),
103
+ writeControl: rendered?.hint ?? describePolicy(policy),
81
104
  next: operations.length > 0
82
105
  ? 'Call the chosen tool by name; it is now listed and callable.'
83
106
  : `No ${service} operation matches "${query}". Call again without query for the full catalog.`,
@@ -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;
@@ -42,5 +49,8 @@ export declare function loadMethodIndex(api: string, deps?: DiscoveryDeps): Prom
42
49
  export declare function clearDiscoveryMemoryCache(): void;
43
50
  export declare function cudFromMethod(method: Pick<DiscoveryMethod, 'httpMethod' | 'id'>): 'read' | 'create' | 'update' | 'delete';
44
51
  export declare function expandPath(template: string, pathParams: Record<string, string>): string;
52
+ /** Search keywords are a few words; the caps keep a caller-sized query from
53
+ * driving index-size x token-count substring scans on the event loop. */
54
+ export declare const MAX_SEARCH_QUERY = 500;
45
55
  export declare function searchMethods(index: DiscoveryMethod[], query: string, limit?: number): DiscoveryMethod[];
46
56
  export {};
@@ -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;
@@ -40,6 +74,21 @@ export function discoveryCacheDir(env = process.env) {
40
74
  return (env.DISCOVERY_CACHE_PATH ??
41
75
  path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'mcp-google-multi', 'discovery'));
42
76
  }
77
+ // Default cache path only (an operator's DISCOVERY_CACHE_PATH may be shared,
78
+ // even /tmp, and keeps its mode), only when this process owns it, and best
79
+ // effort. Clears group/other bits and keeps setuid/setgid/sticky.
80
+ function tightenOwnDir(dir) {
81
+ if (process.platform === 'win32')
82
+ return;
83
+ try {
84
+ const st = fs.statSync(dir);
85
+ if (st.uid === process.getuid?.() && (st.mode & 0o077) !== 0)
86
+ fs.chmodSync(dir, st.mode & ~0o077 & 0o7777);
87
+ }
88
+ catch {
89
+ // the cache still works with the directory's own mode
90
+ }
91
+ }
43
92
  // A poisoned cache file could repoint baseUrl off-Google; refuse to send the
44
93
  // user's Bearer token anywhere but a googleapis.com host.
45
94
  export function isGoogleApiUrl(url) {
@@ -96,11 +145,12 @@ export async function loadMethodIndex(api, deps = {}) {
96
145
  const cached = memoryCache.get(api);
97
146
  if (cached && now() < cached.refreshAfter)
98
147
  return cached.index;
99
- const spec = WORKSPACE_APIS[api];
148
+ const spec = SUPPORTED_APIS[api];
100
149
  if (!spec) {
101
- throw new Error(`Unknown api "${api}". Known: ${Object.keys(WORKSPACE_APIS).join(', ')}`);
150
+ throw new Error(`Unknown api "${api}". Known: ${Object.keys(SUPPORTED_APIS).join(', ')}`);
102
151
  }
103
152
  const fetchFn = deps.fetchFn ?? ((url) => fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }));
153
+ const defaultDir = deps.cacheDir === undefined && process.env.DISCOVERY_CACHE_PATH === undefined;
104
154
  const cacheDir = deps.cacheDir ?? discoveryCacheDir();
105
155
  const cacheFile = path.join(cacheDir, `${api}.json`);
106
156
  const readCache = () => {
@@ -124,20 +174,36 @@ export async function loadMethodIndex(api, deps = {}) {
124
174
  if (cacheFresh())
125
175
  doc = readCache();
126
176
  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 });
177
+ // Newer APIs (analyticsadmin/analyticsdata) are absent from the central
178
+ // discovery directory (404); each service's own $discovery endpoint is
179
+ // authoritative, so try both — same fallback as scripts/fetch-discovery.
180
+ const urls = [
181
+ `https://www.googleapis.com/discovery/v1/apis/${spec.id}/${spec.version}/rest`,
182
+ `https://${spec.id}.googleapis.com/$discovery/rest?version=${spec.version}`,
183
+ ];
184
+ let fetchError;
185
+ for (const url of urls) {
186
+ try {
187
+ const res = await fetchFn(url);
188
+ if (!res.ok)
189
+ throw new Error(`HTTP ${res.status}`);
190
+ doc = (await res.json());
191
+ fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
192
+ if (defaultDir)
193
+ tightenOwnDir(cacheDir);
194
+ fs.writeFileSync(cacheFile, JSON.stringify(doc), { mode: 0o600 });
195
+ break;
196
+ }
197
+ catch (err) {
198
+ fetchError = err;
199
+ doc = undefined;
200
+ }
135
201
  }
136
- catch (err) {
202
+ if (!doc) {
137
203
  doc = readCache();
138
204
  staleFallback = true;
139
205
  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 });
206
+ 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
207
  }
142
208
  }
143
209
  }
@@ -148,9 +214,24 @@ export async function loadMethodIndex(api, deps = {}) {
148
214
  export function clearDiscoveryMemoryCache() {
149
215
  memoryCache.clear();
150
216
  }
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;
217
+ // GA4-style report execution (runReport, batchRunPivotReports, runAccessReport)
218
+ // and check* predicates are POSTs purely for the request-body size — reads.
219
+ const POST_READ_VERB = /^(get|list|search|query|lookup|count|batchGet|generateIds|export|download|inspect|check|suggest|(batch)?run\w*report)/i;
220
+ // `cancelWipe` must be matched here, BEFORE `cancel` reaches the delete list:
221
+ // it calls off a pending wipe, which is the opposite of one. Update is tested
222
+ // before delete for exactly that reason.
223
+ // State transitions on something that already exists: reversible, and none of
224
+ // them creates anything, so `create` was both wrong and the most permissive
225
+ // class available. `(batch)?` mirrors the delete list, without which every
226
+ // `batchUpdate*` POST fell through to `create`.
227
+ 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;
228
+ // archive sits with the deletes: in GA4 archiving a custom dimension/metric is
229
+ // permanent, so the most restrictive write class is the safe classification.
230
+ // So do the teardown verbs: `stop` and `cancel` tear down a push channel or a
231
+ // long-running operation, `wipe`/`invalidate`/`signOut` destroy device data,
232
+ // codes and sessions, `reset` discards a configuration, and `end` terminates a
233
+ // live conference. Each removes something that existed.
234
+ 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
235
  export function cudFromMethod(method) {
155
236
  switch (method.httpMethod) {
156
237
  case 'GET':
@@ -181,11 +262,32 @@ export function expandPath(template, pathParams) {
181
262
  if (value === undefined) {
182
263
  throw new Error(`Missing required path parameter "${name}"`);
183
264
  }
184
- return plus ? String(value).split('/').map(encodeURIComponent).join('/') : encodeURIComponent(String(value));
265
+ // A blank segment collapses the path and Google routes the resulting
266
+ // trailing-slash URL to the COLLECTION: `sites.get` with siteUrl:"" came
267
+ // back as the whole site LIST, reported as success. Dot segments resolve
268
+ // at URL-parse time, so `../v1/contactGroups` retargets the call at a
269
+ // different endpoint and returns ITS response as success. Both are
270
+ // silent-wrong, so refuse locally rather than send a different request.
271
+ // `{+param}` keeps its slashes, so every segment has to be checked.
272
+ const segments = plus ? String(value).split('/') : [String(value)];
273
+ for (const segment of segments) {
274
+ if (segment.trim() === '') {
275
+ throw new Error(`Path parameter "${name}" is empty. An empty value addresses the collection, not one resource, so nothing was sent to Google.`);
276
+ }
277
+ if (segment === '.' || segment === '..') {
278
+ 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.`);
279
+ }
280
+ }
281
+ return segments.map(encodeURIComponent).join('/');
185
282
  });
186
283
  }
284
+ /** Search keywords are a few words; the caps keep a caller-sized query from
285
+ * driving index-size x token-count substring scans on the event loop. */
286
+ export const MAX_SEARCH_QUERY = 500;
287
+ const MAX_SEARCH_TOKENS = 16;
187
288
  export function searchMethods(index, query, limit = 10) {
188
- const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
289
+ const words = query.slice(0, MAX_SEARCH_QUERY).toLowerCase().split(/\s+/).filter(Boolean);
290
+ const tokens = [...new Set(words)].slice(0, MAX_SEARCH_TOKENS);
189
291
  if (tokens.length === 0)
190
292
  return index.slice(0, limit);
191
293
  const scored = index
package/dist/doctor.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ToolRegistry } from './registry.js';
2
2
  import { getAccountSet } from './accounts.js';
3
3
  import { type AccountHealth } from './tools/accounts-tool.js';
4
+ import { type IdentityContext } from './identity.js';
4
5
  import { peekMasterKeyProvenance } from './master-key.js';
5
6
  import { type HttpConfig } from './http-config.js';
6
7
  export type Verdict = 'ok' | 'warn' | 'fail' | 'unknown';
@@ -53,9 +54,14 @@ export interface HttpProbeResult {
53
54
  }
54
55
  /** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
55
56
  export declare function apiEnableLink(api: string): string;
57
+ /** One line, state AND source, so the people being measured can see both
58
+ * here and in `diagnose` (metrics spec section 2). Read-only. */
59
+ export declare function usageMetricsStatusLine(env: Record<string, string | undefined>): string;
56
60
  /** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
57
61
  export declare function overallVerdict(sections: DiagnosticSection[]): Verdict;
58
- export declare function runDiagnostics(deps?: DiagnosticsDeps): Promise<DiagnosticsReport>;
62
+ export declare function runDiagnostics(deps?: DiagnosticsDeps, opts?: {
63
+ scope?: 'operator' | 'tenant';
64
+ }): Promise<DiagnosticsReport>;
59
65
  /** doctor CLI (brew-doctor style): glyph-coded human report. */
60
66
  export declare function renderDoctorText(report: DiagnosticsReport): string;
61
67
  /** Redacted, paste-ready bug report (BR8): verdicts + provenance labels + token
@@ -63,9 +69,15 @@ export declare function renderDoctorText(report: DiagnosticsReport): string;
63
69
  export declare function renderReport(report: DiagnosticsReport, version: string): string;
64
70
  /** Exit non-zero when any section FAILs; with strict, WARN counts too. */
65
71
  export declare function exitCodeFor(report: DiagnosticsReport, strict: boolean): number;
72
+ export type DiagnoseContext = Pick<IdentityContext, 'subject' | 'accounts' | 'getClient' | 'tokenStore'>;
66
73
  /** Agent-callable structured health report (read-only). Mirrors `doctor`'s
67
- * engine; NOT alwaysLoad (the agent asks for it when diagnosing). */
68
- export declare function registerDiagnoseTool(registry: ToolRegistry): void;
74
+ * engine. Registered as a META tool, like `account_list`: it introspects this
75
+ * server rather than Google data, and as a normal tool it became a service of
76
+ * its own with no `{service}_discover` to reveal it (discover tools are built
77
+ * from already-registered services, and this runs after that), so it was
78
+ * advertised only once something else expanded the surface. The README sends
79
+ * people here when they are stuck, so it has to be findable. */
80
+ export declare function registerDiagnoseTool(registry: ToolRegistry, ctx?: DiagnoseContext): void;
69
81
  export interface ResetOptions {
70
82
  account?: string;
71
83
  regenerateKey: boolean;