mcp-google-multi 6.0.0-beta.2 → 6.0.0-beta.4

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 (181) hide show
  1. package/README.md +5 -5
  2. package/dist/accounts.d.ts +20 -4
  3. package/dist/accounts.js +68 -44
  4. package/dist/api-probe.d.ts +5 -0
  5. package/dist/api-probe.js +20 -15
  6. package/dist/arg-normalize.js +19 -38
  7. package/dist/arg-strict.d.ts +7 -0
  8. package/dist/arg-strict.js +38 -28
  9. package/dist/auth.d.ts +5 -4
  10. package/dist/auth.js +16 -23
  11. package/dist/boot-gates.d.ts +26 -0
  12. package/dist/boot-gates.js +36 -0
  13. package/dist/cimd-cache.d.ts +38 -0
  14. package/dist/cimd-cache.js +111 -0
  15. package/dist/client-config.d.ts +2 -2
  16. package/dist/client-config.js +8 -14
  17. package/dist/client.d.ts +32 -2
  18. package/dist/client.js +0 -0
  19. package/dist/compose.d.ts +18 -0
  20. package/dist/compose.js +109 -0
  21. package/dist/config-file.d.ts +13 -0
  22. package/dist/config-file.js +41 -10
  23. package/dist/discover.js +7 -10
  24. package/dist/discovery-client.d.ts +3 -0
  25. package/dist/discovery-client.js +36 -32
  26. package/dist/doctor.d.ts +9 -5
  27. package/dist/doctor.js +119 -29
  28. package/dist/env-load.js +2 -5
  29. package/dist/executor.d.ts +3 -0
  30. package/dist/executor.js +6 -6
  31. package/dist/fanout.d.ts +9 -1
  32. package/dist/fanout.js +47 -22
  33. package/dist/fs-atomic.d.ts +8 -0
  34. package/dist/fs-atomic.js +216 -29
  35. package/dist/hint-provider.d.ts +129 -0
  36. package/dist/hint-provider.js +58 -0
  37. package/dist/http-config.d.ts +2 -2
  38. package/dist/http-config.js +3 -7
  39. package/dist/http-transport.d.ts +67 -9
  40. package/dist/http-transport.js +318 -52
  41. package/dist/identity.d.ts +6 -3
  42. package/dist/identity.js +24 -8
  43. package/dist/index.js +76 -160
  44. package/dist/master-key.d.ts +3 -3
  45. package/dist/master-key.js +16 -19
  46. package/dist/mcp-token.d.ts +126 -19
  47. package/dist/mcp-token.js +557 -76
  48. package/dist/metrics-cli.d.ts +1 -1
  49. package/dist/metrics-cli.js +1 -1
  50. package/dist/metrics-tap.js +2 -5
  51. package/dist/migrate-config.d.ts +1 -2
  52. package/dist/migrate-config.js +5 -7
  53. package/dist/net-tuning.js +2 -4
  54. package/dist/oauth-as.d.ts +104 -4
  55. package/dist/oauth-as.js +368 -114
  56. package/dist/oauth-consent.d.ts +10 -0
  57. package/dist/oauth-consent.js +20 -10
  58. package/dist/outbound-allowlist.js +9 -11
  59. package/dist/reauth-hint.d.ts +1 -1
  60. package/dist/reauth-hint.js +11 -10
  61. package/dist/registry-transfer.d.ts +5 -0
  62. package/dist/registry-transfer.js +63 -11
  63. package/dist/registry.d.ts +13 -4
  64. package/dist/registry.js +100 -72
  65. package/dist/scope-catalog.d.ts +16 -0
  66. package/dist/scope-catalog.js +40 -13
  67. package/dist/scope-observability.d.ts +10 -9
  68. package/dist/scope-observability.js +34 -24
  69. package/dist/services.d.ts +18 -10
  70. package/dist/services.js +61 -22
  71. package/dist/ssrf-guard.d.ts +23 -9
  72. package/dist/ssrf-guard.js +106 -78
  73. package/dist/tenant-purge.d.ts +26 -0
  74. package/dist/tenant-purge.js +65 -0
  75. package/dist/token-store.d.ts +20 -2
  76. package/dist/token-store.js +0 -0
  77. package/dist/tools/_coerce.d.ts +4 -4
  78. package/dist/tools/_errors.d.ts +3 -0
  79. package/dist/tools/_errors.js +72 -60
  80. package/dist/tools/_local-files.d.ts +9 -0
  81. package/dist/tools/_local-files.js +22 -8
  82. package/dist/tools/account-wizard.d.ts +13 -3
  83. package/dist/tools/account-wizard.js +107 -56
  84. package/dist/tools/accounts-tool.d.ts +8 -1
  85. package/dist/tools/accounts-tool.js +35 -19
  86. package/dist/tools/admin.d.ts +10 -1
  87. package/dist/tools/admin.js +14 -11
  88. package/dist/tools/analytics.d.ts +10 -1
  89. package/dist/tools/analytics.js +14 -10
  90. package/dist/tools/calendar.d.ts +2 -1
  91. package/dist/tools/calendar.js +19 -18
  92. package/dist/tools/chat.d.ts +10 -1
  93. package/dist/tools/chat.js +12 -8
  94. package/dist/tools/contacts.d.ts +2 -1
  95. package/dist/tools/contacts.js +22 -19
  96. package/dist/tools/docs.d.ts +2 -1
  97. package/dist/tools/docs.js +35 -36
  98. package/dist/tools/drive.d.ts +2 -1
  99. package/dist/tools/drive.js +80 -74
  100. package/dist/tools/forms.d.ts +10 -1
  101. package/dist/tools/forms.js +18 -12
  102. package/dist/tools/generated/_shared.d.ts +2 -1
  103. package/dist/tools/generated/_shared.js +7 -3
  104. package/dist/tools/generated/admin.d.ts +2 -1
  105. package/dist/tools/generated/admin.js +265 -265
  106. package/dist/tools/generated/analytics.d.ts +2 -1
  107. package/dist/tools/generated/analytics.js +125 -125
  108. package/dist/tools/generated/appsmarket.d.ts +2 -1
  109. package/dist/tools/generated/appsmarket.js +5 -5
  110. package/dist/tools/generated/calendar.d.ts +2 -1
  111. package/dist/tools/generated/calendar.js +55 -55
  112. package/dist/tools/generated/chat.d.ts +2 -1
  113. package/dist/tools/generated/chat.js +95 -95
  114. package/dist/tools/generated/classroom.d.ts +2 -1
  115. package/dist/tools/generated/classroom.js +209 -209
  116. package/dist/tools/generated/cloudidentity.d.ts +2 -1
  117. package/dist/tools/generated/cloudidentity.js +133 -133
  118. package/dist/tools/generated/cloudsearch.d.ts +2 -1
  119. package/dist/tools/generated/cloudsearch.js +99 -99
  120. package/dist/tools/generated/contacts.d.ts +2 -1
  121. package/dist/tools/generated/contacts.js +29 -29
  122. package/dist/tools/generated/drive.d.ts +2 -1
  123. package/dist/tools/generated/drive.js +71 -71
  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 +53 -53
  128. package/dist/tools/generated/forms.d.ts +2 -1
  129. package/dist/tools/generated/forms.js +7 -7
  130. package/dist/tools/generated/gmail.d.ts +2 -1
  131. package/dist/tools/generated/gmail.js +117 -117
  132. package/dist/tools/generated/groupsmigration.d.ts +2 -1
  133. package/dist/tools/generated/groupsmigration.js +3 -3
  134. package/dist/tools/generated/groupssettings.d.ts +2 -1
  135. package/dist/tools/generated/groupssettings.js +7 -7
  136. package/dist/tools/generated/index.d.ts +2 -1
  137. package/dist/tools/generated/keep.d.ts +2 -1
  138. package/dist/tools/generated/keep.js +15 -15
  139. package/dist/tools/generated/licensing.d.ts +2 -1
  140. package/dist/tools/generated/licensing.js +15 -15
  141. package/dist/tools/generated/meet.d.ts +2 -1
  142. package/dist/tools/generated/meet.js +27 -27
  143. package/dist/tools/generated/postmaster.d.ts +2 -1
  144. package/dist/tools/generated/postmaster.js +9 -9
  145. package/dist/tools/generated/reseller.d.ts +2 -1
  146. package/dist/tools/generated/reseller.js +35 -35
  147. package/dist/tools/generated/script.d.ts +2 -1
  148. package/dist/tools/generated/script.js +33 -33
  149. package/dist/tools/generated/searchconsole.d.ts +2 -1
  150. package/dist/tools/generated/searchconsole.js +3 -3
  151. package/dist/tools/generated/sheets.d.ts +2 -1
  152. package/dist/tools/generated/sheets.js +15 -15
  153. package/dist/tools/generated/tasks.d.ts +2 -1
  154. package/dist/tools/generated/tasks.js +5 -5
  155. package/dist/tools/generated/vault.d.ts +2 -1
  156. package/dist/tools/generated/vault.js +67 -67
  157. package/dist/tools/generated/workspaceevents.d.ts +2 -1
  158. package/dist/tools/generated/workspaceevents.js +31 -31
  159. package/dist/tools/gmail-mime.d.ts +1 -1
  160. package/dist/tools/gmail-mime.js +7 -12
  161. package/dist/tools/gmail.d.ts +4 -3
  162. package/dist/tools/gmail.js +67 -69
  163. package/dist/tools/google-api.d.ts +2 -3
  164. package/dist/tools/google-api.js +34 -22
  165. package/dist/tools/meet.d.ts +10 -1
  166. package/dist/tools/meet.js +12 -9
  167. package/dist/tools/searchconsole.d.ts +2 -1
  168. package/dist/tools/searchconsole.js +16 -13
  169. package/dist/tools/sheets.d.ts +2 -1
  170. package/dist/tools/sheets.js +35 -32
  171. package/dist/tools/slides.d.ts +10 -1
  172. package/dist/tools/slides.js +16 -10
  173. package/dist/tools/tasks.d.ts +2 -1
  174. package/dist/tools/tasks.js +18 -15
  175. package/dist/trim.d.ts +5 -0
  176. package/dist/trim.js +19 -0
  177. package/dist/usage-metrics.d.ts +23 -0
  178. package/dist/usage-metrics.js +67 -56
  179. package/dist/write-control.d.ts +4 -8
  180. package/dist/write-control.js +13 -6
  181. package/package.json +47 -21
package/dist/doctor.js CHANGED
@@ -2,16 +2,19 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as readline from 'node:readline';
4
4
  import { getAccountSet } from './accounts.js';
5
- import { deriveAccountHealth } from './tools/accounts-tool.js';
5
+ import { accountHealthDepsFor, deriveAccountHealth } from './tools/accounts-tool.js';
6
+ import { isOwnerContext } from './identity.js';
6
7
  import { peekMasterKeyProvenance, deleteMasterKeyMaterial } from './master-key.js';
7
8
  import { hasToken } from './token-store.js';
8
9
  import { configDir, loadConfigFile } from './config-file.js';
9
10
  import { envValueSource } from './env-load.js';
11
+ import { reauthHint } from './reauth-hint.js';
10
12
  import { describeMetricsDir, resolveUsageMetrics, sourceLabel } from './usage-metrics.js';
11
- import { probeApiEnablement } from './api-probe.js';
13
+ import { apiProbeDepsFor, probeApiEnablement } from './api-probe.js';
12
14
  import { safeMessage, stringifyEnvelope } from './tools/_errors.js';
13
15
  import { resolveHttpConfig, HttpConfigError } from './http-config.js';
14
16
  import { parseOwnerEmails } from './http-transport.js';
17
+ import { hints } from './hint-provider.js';
15
18
  const MIN_NODE_MAJOR = 22;
16
19
  const DEFAULT_DEPS = {
17
20
  nodeVersion: process.versions.node,
@@ -32,9 +35,8 @@ const DEFAULT_DEPS = {
32
35
  probeApi: (alias) => probeApiEnablement(alias),
33
36
  probeHttp: (cfg) => probeHttpEndpoints(cfg),
34
37
  };
35
- /** §7 live check: the advertised OAuth metadata must derive from MCP_PUBLIC_URL
36
- * exactly — one mismatch between PRM `resource` / AS `issuer` and what clients
37
- * compute from the public URL is the perpetual-401 interop bug (BR4). */
38
+ // §7 live check: a mismatch between PRM/AS metadata and what clients compute
39
+ // from the public URL is the perpetual-401 interop bug.
38
40
  async function probeHttpEndpoints(cfg) {
39
41
  try {
40
42
  const prmRes = await fetch(`${cfg.publicUrl}/.well-known/oauth-protected-resource`, {
@@ -63,14 +65,27 @@ async function probeHttpEndpoints(cfg) {
63
65
  return { ok: false, unreachable: true };
64
66
  }
65
67
  }
66
- /** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
68
+ /** Console deep-link to enable one API (section-6 hint). */
67
69
  export function apiEnableLink(api) {
68
70
  return `https://console.cloud.google.com/apis/library/${api}.googleapis.com`;
69
71
  }
70
72
  const LEGACY_ENV_KEYS = ['GOOGLE_ACCOUNTS', 'GOOGLE_OPTIONAL_SCOPES', 'GOOGLE_ADMIN_ACCOUNTS'];
71
- function sectionRuntime(deps) {
73
+ function sectionRuntime(deps, tenantScope = false) {
72
74
  const major = Number.parseInt(deps.nodeVersion.split('.')[0] ?? '0', 10);
73
75
  if (!Number.isFinite(major) || major < MIN_NODE_MAJOR) {
76
+ if (tenantScope) {
77
+ // The host version is operator infrastructure, not the tenant's: no
78
+ // version string, and the fix is the operator's to apply.
79
+ const rendered = hints()?.render({ kind: 'operator_only', slug: 'E_NODE_TOO_OLD' });
80
+ return {
81
+ id: 1,
82
+ title: 'Runtime',
83
+ verdict: 'fail',
84
+ slug: 'E_NODE_TOO_OLD',
85
+ lines: ['Runtime below the supported version'],
86
+ hint: rendered?.hint ?? 'Tell the server operator the code E_NODE_TOO_OLD.',
87
+ };
88
+ }
74
89
  return {
75
90
  id: 1,
76
91
  title: 'Runtime',
@@ -80,23 +95,36 @@ function sectionRuntime(deps) {
80
95
  hint: `Upgrade to Node ${MIN_NODE_MAJOR} LTS or newer.`,
81
96
  };
82
97
  }
83
- return { id: 1, title: 'Runtime', verdict: 'ok', lines: [`Node.js ${deps.nodeVersion} (>= ${MIN_NODE_MAJOR})`] };
98
+ return {
99
+ id: 1,
100
+ title: 'Runtime',
101
+ verdict: 'ok',
102
+ lines: [tenantScope ? 'Runtime ok' : `Node.js ${deps.nodeVersion} (>= ${MIN_NODE_MAJOR})`],
103
+ };
84
104
  }
85
- function sectionConfig(deps, set) {
105
+ function sectionConfig(deps, set, tenantScope = false) {
86
106
  const lines = [];
87
107
  let verdict = 'ok';
88
108
  let hint;
89
109
  let slug;
90
110
  if (!set || set.aliases.length === 0) {
111
+ const tenantHint = tenantScope ? (hints()?.render({ kind: 'no_accounts' })?.hint ?? '') : undefined;
91
112
  return {
92
113
  id: 2,
93
114
  title: 'Config',
94
115
  verdict: 'fail',
95
116
  slug: 'E_NO_ACCOUNTS_CONFIGURED',
96
117
  lines: ['No accounts configured.'],
97
- hint: 'Add an account: run `npx mcp-google-multi migrate-config` or set GOOGLE_ACCOUNTS.',
118
+ ...(tenantScope
119
+ ? (tenantHint ? { hint: tenantHint } : {})
120
+ : { hint: 'Add an account: run `npx mcp-google-multi migrate-config` or set GOOGLE_ACCOUNTS.' }),
98
121
  };
99
122
  }
123
+ // The config file, process env, working directory and metrics store below
124
+ // are the operator's host, not the context's: a tenant sees its own count.
125
+ if (tenantScope) {
126
+ return { id: 2, title: 'Config', verdict: 'ok', lines: [`${set.aliases.length} account(s) configured`] };
127
+ }
100
128
  const cfgPath = path.join(configDir(), 'config.json');
101
129
  lines.push(deps.fileExists(cfgPath) ? `config.json present (${set.aliases.length} account(s))` : `config.json absent — accounts sourced from env (${set.aliases.length})`);
102
130
  const legacyEnv = LEGACY_ENV_KEYS.filter((k) => deps.env[k]);
@@ -121,7 +149,7 @@ function sectionConfig(deps, set) {
121
149
  return { id: 2, title: 'Config', verdict, lines, ...(hint ? { hint } : {}), ...(slug ? { slug } : {}) };
122
150
  }
123
151
  /** One line, state AND source, so the people being measured can see both
124
- * here and in `diagnose` (metrics spec section 2). Read-only. */
152
+ * here and in `diagnose`. Read-only. */
125
153
  export function usageMetricsStatusLine(env) {
126
154
  const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
127
155
  let configValue;
@@ -181,7 +209,8 @@ function sectionsTokensAndScopes(deps, aliases) {
181
209
  if (scopeVerdict === 'ok')
182
210
  scopeVerdict = 'warn';
183
211
  scopeLines.push(`${alias}: ${r.callable.length} callable, ${r.requestable.length} requested-not-granted`);
184
- scopeHint = `Re-auth to grant missing scopes: \`npx mcp-google-multi auth --account ${alias}\`.`;
212
+ const rendered = hints()?.render({ kind: 'scopes_missing', account: alias });
213
+ scopeHint = rendered?.hint ?? `Re-auth to grant missing scopes. ${reauthHint(alias)}`;
185
214
  }
186
215
  else {
187
216
  scopeLines.push(`${alias}: ${r.callable.length} callable, all profile scopes granted`);
@@ -192,7 +221,7 @@ function sectionsTokensAndScopes(deps, aliases) {
192
221
  { id: 5, title: 'Scopes', verdict: scopeVerdict, lines: scopeLines, ...(scopeHint ? { hint: scopeHint } : {}), ...(scopeVerdict === 'warn' ? { slug: 'E_SCOPE_NOT_GRANTED' } : {}) },
193
222
  ];
194
223
  }
195
- async function sectionApiEnablement(deps, aliases) {
224
+ async function sectionApiEnablement(deps, aliases, tenantScope = false) {
196
225
  if (!deps.probeApi) {
197
226
  return { id: 6, title: 'API enablement', verdict: 'unknown', lines: ['Probe not run (no live account probe configured).'] };
198
227
  }
@@ -209,8 +238,14 @@ async function sectionApiEnablement(deps, aliases) {
209
238
  results = await deps.probeApi(healthy);
210
239
  }
211
240
  catch (e) {
212
- // Network / transient: WARN with the target, never crash the report.
213
- return { id: 6, title: 'API enablement', verdict: 'warn', lines: [`Probe could not complete: ${e?.message ?? e}`] };
241
+ // Network / transient: WARN with the target, never crash the report. The
242
+ // raw text can name an egress or proxy host: tenant scope drops it.
243
+ return {
244
+ id: 6,
245
+ title: 'API enablement',
246
+ verdict: 'warn',
247
+ lines: [tenantScope ? 'Probe could not complete.' : `Probe could not complete: ${e?.message ?? e}`],
248
+ };
214
249
  }
215
250
  if (results.length === 0) {
216
251
  return { id: 6, title: 'API enablement', verdict: 'unknown', lines: [`No probeable service scopes granted on "${healthy}".`] };
@@ -218,13 +253,14 @@ async function sectionApiEnablement(deps, aliases) {
218
253
  const disabled = results.filter((r) => r.notEnabled);
219
254
  const lines = results.map((r) => `${r.service}: ${r.ok ? 'enabled' : r.notEnabled ? 'NOT ENABLED' : `unknown (${r.message ?? 'error'})`}`);
220
255
  if (disabled.length > 0) {
256
+ const rendered = hints()?.render({ kind: 'apis_disabled', services: disabled.map((r) => ({ service: r.service, api: r.api })) });
221
257
  return {
222
258
  id: 6,
223
259
  title: 'API enablement',
224
260
  verdict: 'fail',
225
261
  slug: 'E_API_NOT_ENABLED',
226
262
  lines,
227
- hint: disabled.map((r) => `Enable ${r.service}: ${apiEnableLink(r.api)}`).join('\n'),
263
+ hint: rendered?.hint ?? disabled.map((r) => `Enable ${r.service}: ${apiEnableLink(r.api)}`).join('\n'),
228
264
  };
229
265
  }
230
266
  return { id: 6, title: 'API enablement', verdict: 'ok', lines: lines.length ? lines : ['(probed account, all enabled)'] };
@@ -299,21 +335,27 @@ export function overallVerdict(sections) {
299
335
  }
300
336
  return worst;
301
337
  }
302
- export async function runDiagnostics(deps = DEFAULT_DEPS) {
338
+ export async function runDiagnostics(deps = DEFAULT_DEPS, opts = {}) {
339
+ // Tenant scope omits the OPERATOR sections (§3 key provenance, §7 owner-gate
340
+ // emails) and trims §2 to the context's own account count.
341
+ const tenantScope = opts.scope === 'tenant';
303
342
  const sections = [];
304
- sections.push(sectionRuntime(deps));
343
+ sections.push(sectionRuntime(deps, tenantScope));
305
344
  const set = deps.accountSet();
306
- sections.push(sectionConfig(deps, set));
345
+ sections.push(sectionConfig(deps, set, tenantScope));
307
346
  const aliases = set?.aliases ?? [];
308
- sections.push(sectionKeys(deps, aliases));
347
+ if (!tenantScope)
348
+ sections.push(sectionKeys(deps, aliases));
309
349
  if (aliases.length > 0) {
310
350
  const [tokens, scopes] = sectionsTokensAndScopes(deps, aliases);
311
351
  sections.push(tokens, scopes);
312
- sections.push(await sectionApiEnablement(deps, aliases));
352
+ sections.push(await sectionApiEnablement(deps, aliases, tenantScope));
353
+ }
354
+ if (!tenantScope) {
355
+ const http = await sectionHttp(deps, aliases);
356
+ if (http)
357
+ sections.push(http);
313
358
  }
314
- const http = await sectionHttp(deps, aliases);
315
- if (http)
316
- sections.push(http);
317
359
  return { verdict: overallVerdict(sections), sections };
318
360
  }
319
361
  // --- skins -----------------------------------------------------------------
@@ -333,11 +375,35 @@ export function renderDoctorText(report) {
333
375
  out.push(`Overall: ${report.verdict.toUpperCase()}`);
334
376
  return out.join('\n');
335
377
  }
336
- /** Mask the local-part of an email so a report never carries a full address (BR8). */
378
+ const EMAIL_LOCAL_CHAR = /[A-Za-z0-9._%+-]/;
379
+ const EMAIL_DOMAIN_CHAR = /[A-Za-z0-9.-]/;
380
+ // A linear scan, not a regex: `local*@domain` backtracks polynomially on long
381
+ // local-charset runs without an @ (CodeQL js/polynomial-redos).
337
382
  function maskEmails(text) {
338
- return text.replace(/([A-Za-z0-9._%+-])[A-Za-z0-9._%+-]*(@[A-Za-z0-9.-]+)/g, '$1***$2');
383
+ let out = '';
384
+ let i = 0;
385
+ while (i < text.length) {
386
+ if (text[i] === '@') {
387
+ let j = i + 1;
388
+ while (j < text.length && EMAIL_DOMAIN_CHAR.test(text[j]))
389
+ j += 1;
390
+ let k = out.length;
391
+ while (k > 0 && EMAIL_LOCAL_CHAR.test(out[k - 1]))
392
+ k -= 1;
393
+ if (j > i + 1 && k < out.length) {
394
+ // keep the first local char, mask the rest ('*' is outside the local
395
+ // charset, so a masked run can never be re-masked by a later @)
396
+ out = `${out.slice(0, k + 1)}***${text.slice(i, j)}`;
397
+ i = j;
398
+ continue;
399
+ }
400
+ }
401
+ out += text[i];
402
+ i += 1;
403
+ }
404
+ return out;
339
405
  }
340
- /** Redacted, paste-ready bug report (BR8): verdicts + provenance labels + token
406
+ /** Redacted, paste-ready bug report: verdicts + provenance labels + token
341
407
  * statuses + slugs; NO token values, secrets, keys, or full email addresses. */
342
408
  export function renderReport(report, version) {
343
409
  const out = [`mcp-google-multi doctor report (v${version})`, `overall: ${report.verdict}`, ''];
@@ -356,6 +422,26 @@ export function exitCodeFor(report, strict) {
356
422
  return 1;
357
423
  return 0;
358
424
  }
425
+ /** The engine's account, token and probe reads bound to one context, so a
426
+ * report never shows (or probes with) another context's accounts. */
427
+ function diagnosticsDepsFor(ctx) {
428
+ const health = accountHealthDepsFor(ctx.tokenStore, () => ctx.accounts);
429
+ const probe = apiProbeDepsFor(ctx.tokenStore.readToken, ctx.getClient);
430
+ return {
431
+ ...DEFAULT_DEPS,
432
+ accountSet: () => {
433
+ try {
434
+ return ctx.accounts;
435
+ }
436
+ catch {
437
+ return null;
438
+ }
439
+ },
440
+ accountHealth: (alias) => deriveAccountHealth(alias, health),
441
+ anyTokensExist: (aliases) => aliases.some((a) => ctx.tokenStore.hasToken(a)),
442
+ probeApi: (alias) => probeApiEnablement(alias, probe),
443
+ };
444
+ }
359
445
  /** Agent-callable structured health report (read-only). Mirrors `doctor`'s
360
446
  * engine. Registered as a META tool, like `account_list`: it introspects this
361
447
  * server rather than Google data, and as a normal tool it became a service of
@@ -363,14 +449,18 @@ export function exitCodeFor(report, strict) {
363
449
  * from already-registered services, and this runs after that), so it was
364
450
  * advertised only once something else expanded the surface. The README sends
365
451
  * people here when they are stuck, so it has to be findable. */
366
- export function registerDiagnoseTool(registry) {
452
+ export function registerDiagnoseTool(registry, ctx) {
453
+ // A non-owner context gets the tenant-scoped report; only the owner context
454
+ // (or the context-free CLI surface) keeps the full operator report.
455
+ const scope = ctx && !isOwnerContext(ctx) ? 'tenant' : 'operator';
456
+ const deps = ctx ? diagnosticsDepsFor(ctx) : DEFAULT_DEPS;
367
457
  registry.registerMeta('diagnose', {
368
458
  annotations: { readOnlyHint: true, openWorldHint: true },
369
459
  description: 'Health report for this server: runtime, config, keys, per-account token status, scope grants, and API enablement. Read-only; returns copy-pasteable fixes for anything wrong. Call this to diagnose auth/config failures.',
370
460
  inputSchema: {},
371
461
  }, async () => {
372
462
  try {
373
- const result = await runDiagnostics();
463
+ const result = await runDiagnostics(deps, { scope });
374
464
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
375
465
  }
376
466
  catch (e) {
package/dist/env-load.js CHANGED
@@ -3,11 +3,8 @@ import path from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
- // Precedence: real env > CWD .env > package-root .env > ~/.config .env.
7
- // process.loadEnvFile never overwrites keys already in process.env (pinned by
8
- // tests/env-load.test.ts T4), so highest-priority file loads FIRST and real
9
- // env wins by already being present; the boot snapshot re-assert is a second
10
- // line of defense should those semantics ever change.
6
+ // Precedence: real env > CWD .env > package-root .env > ~/.config .env;
7
+ // the boot snapshot re-assert below is a second line of defense.
11
8
  export function loadEnvFiles(paths = {}) {
12
9
  // loadEnvFile exists since 20.12, so a feature check alone would let 20.12-21.x through.
13
10
  const major = Number(process.versions.node.split('.', 1)[0]);
@@ -1,4 +1,5 @@
1
1
  import { getClient } from './client.js';
2
+ import { type ScopeSetDeps } from './scope-observability.js';
2
3
  export declare const MAX_RESPONSE_CHARS = 100000;
3
4
  export type QueryValue = string | number | boolean;
4
5
  export type QueryParams = Record<string, QueryValue | QueryValue[]>;
@@ -13,6 +14,8 @@ export interface ApiMethodRef {
13
14
  }
14
15
  export interface ExecuteDeps {
15
16
  getClientFn?: typeof getClient;
17
+ /** Token + profile view behind a 403 scope hint; absent = the single owner. */
18
+ scopeDeps?: ScopeSetDeps;
16
19
  }
17
20
  export interface ExecuteArgs {
18
21
  account: string;
package/dist/executor.js CHANGED
@@ -4,6 +4,7 @@ import { expandPath, isGoogleApiUrl } from './discovery-client.js';
4
4
  import { handleGoogleApiError } from './tools/_errors.js';
5
5
  import { scopeHintForMethod } from './scope-observability.js';
6
6
  import { checkOutboundForMethod } from './outbound-allowlist.js';
7
+ import { hints } from './hint-provider.js';
7
8
  export const MAX_RESPONSE_CHARS = 100_000;
8
9
  export function jsonResult(payload, isError = false) {
9
10
  const base = { content: [{ type: 'text', text: JSON.stringify(payload) }] };
@@ -70,10 +71,11 @@ export async function executeApiMethod(method, args, deps = {}) {
70
71
  }, true);
71
72
  }
72
73
  if (!isGoogleApiUrl(url)) {
74
+ const rendered = hints()?.render({ kind: 'untrusted_host' });
73
75
  return jsonResult({
74
76
  error: 'untrusted_host',
75
77
  message: `Refusing to send credentials to a non-googleapis.com host for "${method.id}".`,
76
- hint: 'The discovery cache may be corrupt; delete DISCOVERY_CACHE_PATH and retry.',
78
+ hint: rendered?.hint ?? 'The discovery cache may be corrupt; delete DISCOVERY_CACHE_PATH and retry.',
77
79
  retriable: false,
78
80
  account: args.account,
79
81
  }, true);
@@ -89,10 +91,8 @@ export async function executeApiMethod(method, args, deps = {}) {
89
91
  });
90
92
  const text = JSON.stringify(res.data ?? null);
91
93
  if (text.length > MAX_RESPONSE_CHARS) {
92
- // Budget the ENCODED head: jsonResult re-stringifies it, so every quote
93
- // and backslash inside doubles. Slicing raw characters made the emitted
94
- // result exceed the cap this tool advertises, by up to 15 percent on a
95
- // quote-dense listing.
94
+ // Budget the ENCODED head: jsonResult re-stringifies it, so slicing raw
95
+ // characters overshot the advertised cap on a quote-dense listing.
96
96
  return jsonResult({
97
97
  truncated: true,
98
98
  totalChars: text.length,
@@ -103,6 +103,6 @@ export async function executeApiMethod(method, args, deps = {}) {
103
103
  return { content: [{ type: 'text', text }] };
104
104
  }
105
105
  catch (error) {
106
- return handleGoogleApiError(error, args.account, undefined, () => method.scopes?.length ? scopeHintForMethod(method.scopes, args.account) : null);
106
+ return handleGoogleApiError(error, args.account, undefined, () => method.scopes?.length ? scopeHintForMethod(method.scopes, args.account, deps.scopeDeps) : null);
107
107
  }
108
108
  }
package/dist/fanout.d.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import { z } from 'zod';
2
2
  export declare const CSV_RE: RegExp;
3
- export declare function fanoutAccountField(description: string): z.ZodType;
3
+ export declare function fanoutAccountField(description: string, aliases: () => readonly string[]): z.ZodType;
4
+ /** '*' over an empty account set would fan out to nothing and report success. */
5
+ export declare function noAccountsResult(): {
6
+ content: {
7
+ type: "text";
8
+ text: string;
9
+ }[];
10
+ isError: true;
11
+ };
4
12
  export type AccountSelector = {
5
13
  ok: true;
6
14
  fanout: boolean;
package/dist/fanout.js CHANGED
@@ -1,28 +1,47 @@
1
1
  import { z } from 'zod';
2
- import { ACCOUNTS, getAccountSet, unknownAliasMessage } from './accounts.js';
2
+ import { getAccountSet, liveAccountCheck, unknownAliasMessage } from './accounts.js';
3
+ import { echoName, echoNames, MAX_SUGGESTED_KEYS } from './arg-strict.js';
4
+ import { hints } from './hint-provider.js';
3
5
  export const CSV_RE = /^[a-zA-Z0-9_-]+(\s*,\s*[a-zA-Z0-9_-]+)+$/;
4
6
  const FANOUT_CONCURRENCY = 5;
5
- export function fanoutAccountField(description) {
6
- const csvExample = ACCOUNTS.length > 1 ? `; or a CSV subset like "${ACCOUNTS.slice(0, 2).join(',')}"` : '';
7
- // The union's OWN error only fires when every branch aborts at the type
8
- // check (a non-string). For a string, zod surfaces the single non-aborted
9
- // branch verbatim, which is the CSV regex, so a mistyped alias used to read
10
- // as "Invalid string: must match pattern /^[a-zA-Z0-9_-]+(,...)/" and never
11
- // named an alias. Hence the same message on BOTH.
12
- const bad = () => unknownAliasMessage(ACCOUNTS, true);
7
+ // `aliases` is a getter read at parse time (not baked), so a registry only
8
+ // ever sees ITS OWN account set, and a mid-session alias (account_add) is valid on tools registered before it existed.
9
+ export function fanoutAccountField(description, aliases) {
10
+ const boot = aliases();
11
+ const csvExample = boot.length > 1 ? `; or a CSV subset like "${boot.slice(0, 2).join(',')}"` : '';
12
+ // Same message on both union branches: for a string, zod surfaces the
13
+ // single non-aborted branch (the CSV regex) verbatim, which never named the alias.
14
+ const bad = () => unknownAliasMessage(aliases(), true);
13
15
  return z
14
- // '*' first so the tuple is statically non-empty even when ACCOUNTS is empty
15
- // (a fresh install): z.enum requires [string, ...string[]].
16
- .union([z.enum(['*', ...ACCOUNTS]), z.string().regex(CSV_RE, { error: bad })], { error: bad })
16
+ // The selector branch stays first: tools/list injects the live
17
+ // '*' + alias enum into it (registry withLiveAccountEnum).
18
+ .union([z.string().check(liveAccountCheck(aliases, true)), z.string().regex(CSV_RE, { error: bad })], { error: bad })
17
19
  .optional()
18
20
  .describe(`${description}; "*" = all accounts${csvExample}; omit for the default account`);
19
21
  }
22
+ /** '*' over an empty account set would fan out to nothing and report success. */
23
+ export function noAccountsResult() {
24
+ return {
25
+ content: [
26
+ {
27
+ type: 'text',
28
+ text: JSON.stringify({
29
+ error: 'validation_error',
30
+ message: 'No Google account is configured, so "*" selects nothing.',
31
+ hint: unknownAliasMessage([]),
32
+ retriable: false,
33
+ account: '*',
34
+ }),
35
+ },
36
+ ],
37
+ isError: true,
38
+ };
39
+ }
20
40
  export function parseAccountSelector(value, accounts = getAccountSet().aliases) {
21
41
  if (value === '*')
22
42
  return { ok: true, fanout: true, aliases: [...accounts] };
23
- // A single alias used to skip validation entirely and reach getClient, whose
24
- // throw landed on the mapper's floor as an unclassified upstream_error. Same
25
- // check and same envelope wherever the alias was typed.
43
+ // A single alias used to skip validation and reach getClient, whose throw
44
+ // landed on the mapper's floor as an unclassified upstream_error.
26
45
  if (!value.includes(',')) {
27
46
  return accounts.includes(value)
28
47
  ? { ok: true, fanout: false, aliases: [value] }
@@ -53,10 +72,11 @@ export function invalidAccountsResult(invalid, accounts = getAccountSet().aliase
53
72
  type: 'text',
54
73
  text: JSON.stringify({
55
74
  error: 'validation_error',
56
- message: `Unknown account alias(es): ${invalid.join(', ')}.`,
75
+ // The caller's CSV can hold any number of tokens: echo a prefix.
76
+ message: `Unknown account alias(es): ${echoNames(invalid)}.`,
57
77
  hint: unknownAliasMessage(accounts, true),
58
78
  retriable: false,
59
- account: invalid.join(','),
79
+ account: invalid.slice(0, MAX_SUGGESTED_KEYS).map(echoName).join(','),
60
80
  }),
61
81
  },
62
82
  ],
@@ -87,10 +107,17 @@ export async function runFanout(handler, args, aliases) {
87
107
  results[i] = res?.isError ? { account, ok: false, error: payload } : { account, ok: true, data: payload };
88
108
  }
89
109
  catch (err) {
110
+ const rendered = hints()?.render({ kind: 'internal', account });
90
111
  results[i] = {
91
112
  account,
92
113
  ok: false,
93
- error: { error: 'internal', message: err.message, retriable: false, account },
114
+ error: {
115
+ error: 'internal',
116
+ message: rendered?.message ?? err.message,
117
+ hint: rendered?.hint,
118
+ retriable: false,
119
+ account,
120
+ },
94
121
  };
95
122
  }
96
123
  }
@@ -98,10 +125,8 @@ export async function runFanout(handler, args, aliases) {
98
125
  await Promise.all(Array.from({ length: Math.min(FANOUT_CONCURRENCY, aliases.length) }, () => worker()));
99
126
  const failed = results.filter((r) => !r.ok).length;
100
127
  if (failed === results.length && results.length > 0) {
101
- // Every account failed, so this IS an error result, and used to carry
102
- // `isError` with no top-level error, hint or retriable: the agent saw a
103
- // successful-looking object flagged as an error, and metrics bucketed it
104
- // as `other`. Hoist a real envelope, keeping the per-account detail.
128
+ // Every account failed, so this IS an error result: hoist a real
129
+ // top-level envelope (error/hint/retriable), keeping the per-account detail.
105
130
  const slugs = [...new Set(results.map((r) => r.error?.error ?? 'internal'))];
106
131
  const hints = [...new Set(results.map((r) => r.error?.hint).filter(Boolean))];
107
132
  const retriable = results.every((r) => r.error?.retriable === true);
@@ -1,3 +1,11 @@
1
+ interface LockIdentity {
2
+ ns: string;
3
+ start: string;
4
+ boot: string;
5
+ }
6
+ /** Test hook: act as a process with this identity (undefined restores the real one). */
7
+ export declare function __setLockIdentityForTest(identity?: LockIdentity): void;
1
8
  export declare function withFileLock<T>(absPath: string, fn: () => T, label?: string): T;
2
9
  export declare function atomicWriteFileSync(absPath: string, contents: string, mode?: number): void;
3
10
  export declare function atomicWriteWithLock(absPath: string, contents: string, mode?: number): void;
11
+ export {};