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

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 (126) hide show
  1. package/README.md +20 -6
  2. package/dist/accounts.d.ts +3 -3
  3. package/dist/accounts.js +18 -31
  4. package/dist/arg-normalize.js +15 -35
  5. package/dist/arg-strict.js +8 -28
  6. package/dist/auth.d.ts +1 -1
  7. package/dist/auth.js +5 -10
  8. package/dist/boot-gates.d.ts +1 -1
  9. package/dist/boot-gates.js +3 -5
  10. package/dist/cimd-cache.js +5 -11
  11. package/dist/client-config.d.ts +2 -2
  12. package/dist/client-config.js +8 -14
  13. package/dist/client.js +0 -0
  14. package/dist/compose.d.ts +8 -1
  15. package/dist/compose.js +11 -11
  16. package/dist/config-file.js +7 -12
  17. package/dist/discover.js +6 -12
  18. package/dist/discovery-client.js +14 -33
  19. package/dist/doctor.d.ts +3 -3
  20. package/dist/doctor.js +12 -18
  21. package/dist/env-load.js +2 -5
  22. package/dist/executor.js +2 -4
  23. package/dist/fanout.js +8 -17
  24. package/dist/fs-atomic.d.ts +0 -3
  25. package/dist/fs-atomic.js +104 -22
  26. package/dist/hint-provider.d.ts +4 -1
  27. package/dist/hint-provider.js +4 -10
  28. package/dist/http-config.d.ts +2 -2
  29. package/dist/http-config.js +3 -7
  30. package/dist/http-transport.d.ts +5 -5
  31. package/dist/http-transport.js +39 -92
  32. package/dist/identity.d.ts +2 -2
  33. package/dist/identity.js +6 -9
  34. package/dist/index.js +19 -31
  35. package/dist/master-key.d.ts +3 -3
  36. package/dist/master-key.js +11 -22
  37. package/dist/mcp-token.d.ts +40 -16
  38. package/dist/mcp-token.js +121 -44
  39. package/dist/metrics-cli.d.ts +1 -1
  40. package/dist/metrics-cli.js +1 -1
  41. package/dist/metrics-tap.js +2 -5
  42. package/dist/migrate-config.d.ts +1 -2
  43. package/dist/migrate-config.js +3 -5
  44. package/dist/net-tuning.js +2 -4
  45. package/dist/oauth-as.d.ts +7 -2
  46. package/dist/oauth-as.js +45 -78
  47. package/dist/oauth-consent.js +4 -10
  48. package/dist/outbound-allowlist.js +4 -9
  49. package/dist/reauth-hint.js +2 -5
  50. package/dist/registry-transfer.d.ts +1 -1
  51. package/dist/registry-transfer.js +5 -13
  52. package/dist/registry.d.ts +3 -9
  53. package/dist/registry.js +97 -76
  54. package/dist/scope-catalog.d.ts +5 -0
  55. package/dist/scope-catalog.js +17 -7
  56. package/dist/scope-observability.d.ts +2 -3
  57. package/dist/scope-observability.js +8 -14
  58. package/dist/services.d.ts +3 -0
  59. package/dist/services.js +5 -10
  60. package/dist/ssrf-guard.js +11 -22
  61. package/dist/tenant-purge.js +2 -4
  62. package/dist/token-store.js +0 -0
  63. package/dist/tools/_coerce.d.ts +4 -4
  64. package/dist/tools/_errors.js +22 -39
  65. package/dist/tools/_local-files.js +4 -8
  66. package/dist/tools/account-wizard.d.ts +4 -3
  67. package/dist/tools/account-wizard.js +48 -54
  68. package/dist/tools/accounts-tool.d.ts +8 -1
  69. package/dist/tools/accounts-tool.js +18 -7
  70. package/dist/tools/admin.js +8 -4
  71. package/dist/tools/analytics.js +32 -16
  72. package/dist/tools/calendar.d.ts +5 -0
  73. package/dist/tools/calendar.js +63 -7
  74. package/dist/tools/chat.js +2 -3
  75. package/dist/tools/contacts.js +19 -14
  76. package/dist/tools/docs.js +45 -21
  77. package/dist/tools/drive.js +72 -39
  78. package/dist/tools/forms.js +2 -3
  79. package/dist/tools/generated/gmail.js +0 -14
  80. package/dist/tools/generated/method-map.js +1 -1
  81. package/dist/tools/gmail-mime.d.ts +1 -1
  82. package/dist/tools/gmail-mime.js +7 -12
  83. package/dist/tools/gmail.d.ts +3 -3
  84. package/dist/tools/gmail.js +135 -52
  85. package/dist/tools/google-api.js +5 -7
  86. package/dist/tools/meet.js +2 -3
  87. package/dist/tools/searchconsole.js +2 -3
  88. package/dist/tools/sheets.d.ts +10 -0
  89. package/dist/tools/sheets.js +111 -5
  90. package/dist/tools/slides.js +2 -3
  91. package/dist/tools/tasks.js +2 -3
  92. package/dist/trim.js +2 -3
  93. package/dist/usage-metrics.js +5 -12
  94. package/dist/write-control.d.ts +4 -8
  95. package/dist/write-control.js +18 -6
  96. package/package.json +24 -22
  97. package/skills/google-workspace/SKILL.md +162 -0
  98. package/skills/google-workspace/reference/admin.md +105 -0
  99. package/skills/google-workspace/reference/analytics.md +70 -0
  100. package/skills/google-workspace/reference/appsmarket.md +5 -0
  101. package/skills/google-workspace/reference/calendar.md +175 -0
  102. package/skills/google-workspace/reference/chat.md +59 -0
  103. package/skills/google-workspace/reference/classroom.md +5 -0
  104. package/skills/google-workspace/reference/cloudidentity.md +5 -0
  105. package/skills/google-workspace/reference/cloudsearch.md +5 -0
  106. package/skills/google-workspace/reference/contacts.md +138 -0
  107. package/skills/google-workspace/reference/docs.md +395 -0
  108. package/skills/google-workspace/reference/drive.md +485 -0
  109. package/skills/google-workspace/reference/driveactivity.md +5 -0
  110. package/skills/google-workspace/reference/drivelabels.md +5 -0
  111. package/skills/google-workspace/reference/forms.md +92 -0
  112. package/skills/google-workspace/reference/gmail.md +305 -0
  113. package/skills/google-workspace/reference/groupsmigration.md +5 -0
  114. package/skills/google-workspace/reference/groupssettings.md +5 -0
  115. package/skills/google-workspace/reference/keep.md +5 -0
  116. package/skills/google-workspace/reference/licensing.md +5 -0
  117. package/skills/google-workspace/reference/meet.md +68 -0
  118. package/skills/google-workspace/reference/postmaster.md +5 -0
  119. package/skills/google-workspace/reference/reseller.md +5 -0
  120. package/skills/google-workspace/reference/script.md +5 -0
  121. package/skills/google-workspace/reference/searchconsole.md +128 -0
  122. package/skills/google-workspace/reference/sheets.md +430 -0
  123. package/skills/google-workspace/reference/slides.md +67 -0
  124. package/skills/google-workspace/reference/tasks.md +167 -0
  125. package/skills/google-workspace/reference/vault.md +5 -0
  126. package/skills/google-workspace/reference/workspaceevents.md +5 -0
@@ -13,9 +13,8 @@ export function configFilePath(env = process.env) {
13
13
  export function tenantsDir(env = process.env) {
14
14
  return path.join(configDir(env), 'tenants');
15
15
  }
16
- // A tenant id becomes a filesystem path segment exactly like an account
17
- // alias, so it rides the same traversal guard. 'owner' is the single-owner
18
- // context's subject and client lane, so no tenant may take it.
16
+ // A tenant id becomes a filesystem path segment like an account alias (same
17
+ // traversal guard); 'owner' is the single-owner subject, so no tenant may take it.
19
18
  function assertTenantId(tenantId) {
20
19
  if (!ALIAS_RE.test(tenantId) || isReservedAlias(tenantId) || tenantId === 'owner') {
21
20
  throw new Error(`E_TENANT_ID_INVALID: invalid tenant id "${tenantId}".`);
@@ -52,9 +51,7 @@ const accountEntrySchema = z.strictObject({
52
51
  admin: z.boolean().optional(),
53
52
  });
54
53
  // .strict() everywhere: any unknown or secret-shaped key (clientSecret,
55
- // masterKey, jwtKey, clientId) fails validation, structurally enforcing
56
- // secrets-env-only. scopeProfiles/defaultAccount/discovery/toolsets are part
57
- // of the frozen envelope; their consumers land in later slices.
54
+ // masterKey, jwtKey, clientId) fails validation, structurally enforcing secrets-env-only.
58
55
  export const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
59
56
  export function isReservedAlias(name) {
60
57
  return RESERVED_ALIASES.includes(name);
@@ -72,9 +69,8 @@ const configSchema = z.strictObject({
72
69
  defaultAccount: z.string().optional(),
73
70
  discovery: z.enum(['lazy', 'curated', 'eager']).optional(),
74
71
  toolsets: z.string().optional(),
75
- // Local usage metrics (metrics-feature-spec): absent = off. Downgrade rule:
76
- // a pre-6.0 build reading a config carrying this key fails E_CONFIG_INVALID
77
- // (strictObject); remove the key first.
72
+ // Absent = off. A pre-6.0 build reading a config carrying this key fails
73
+ // E_CONFIG_INVALID (strictObject); remove the key first to downgrade.
78
74
  usageMetrics: z.boolean().optional(),
79
75
  });
80
76
  export function failStartup(slug, message) {
@@ -125,9 +121,8 @@ export function loadConfigFile(filePath = configFilePath(), onInvalid = 'exit')
125
121
  const issue = result.error.issues[0];
126
122
  fail('E_CONFIG_INVALID', `${filePath}: ${issue.path.join('.') || '(root)'}: ${issue.message}. Secrets never belong in config.json.`);
127
123
  }
128
- // Post-schema because zod's key regex passes these; a __proto__ record key
129
- // is a prototype-pollution foothold, and Object.prototype member names break
130
- // plain-object lookups downstream.
124
+ // Post-schema: zod's key regex passes these, but a __proto__ record key is
125
+ // a prototype-pollution foothold downstream.
131
126
  const data = result.data;
132
127
  for (const [section, keys] of [
133
128
  ['accounts', Object.keys(data.accounts ?? {})],
package/dist/discover.js CHANGED
@@ -1,9 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { describePolicy } from './write-control.js';
3
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.
4
+ // The description budget is the whole point of lazy mode: descriptions carry
5
+ // a CAPPED vocabulary, and the complete catalog stays in this tool's RESULT.
7
6
  const CURATED_OPS_CAP = 10;
8
7
  const GENERATED_GROUPS_CAP = 6;
9
8
  function opVocabulary(registry, service) {
@@ -15,8 +14,7 @@ function opVocabulary(registry, service) {
15
14
  parts.push(`${shown.join(', ')}${more > 0 ? ` +${more} more` : ''}`);
16
15
  }
17
16
  if (generated.length > 0) {
18
- // The generated long tail compresses to its resource groups: denser and
19
- // more selective than any truncated name list.
17
+ // Compresses to resource groups: denser than any truncated name list.
20
18
  const counts = new Map();
21
19
  for (const op of generated) {
22
20
  const group = op.split('_')[0];
@@ -31,10 +29,7 @@ function opVocabulary(registry, service) {
31
29
  }
32
30
  export function registerDiscoverTools(registry, policy) {
33
31
  const registerMeta = registry.registerMeta;
34
- // Agent-controllable runtime expansion (D3 refinement): reveal the whole
35
- // curated quality layer for heavy Google work, collapse to reclaim the
36
- // name/schema context budget after. stdio-only semantics — over stateless
37
- // HTTP the mode is forced curated and these are no-ops.
32
+ // stdio-only semantics: over stateless HTTP the mode is forced curated and these are no-ops.
38
33
  registerMeta('discover_all', {
39
34
  description: 'Reveal ALL curated Google tools at once (instead of per-service discovery). Use when ' +
40
35
  'starting substantial Google work; prefer these over google_api_call. Pair with discover_reset.',
@@ -101,9 +96,8 @@ export function registerDiscoverTools(registry, policy) {
101
96
  service,
102
97
  operations,
103
98
  writeControl: rendered?.hint ?? describePolicy(policy),
104
- next: operations.length > 0
105
- ? 'Call the chosen tool by name; it is now listed and callable.'
106
- : `No ${service} operation matches "${query}". Call again without query for the full catalog.`,
99
+ // catalog() falls back to a full one-line listing when nothing matches, so this is never empty.
100
+ next: 'Call the chosen tool by its exact name. A client that loads tools on demand may need its own tool-search or load step before the call succeeds.',
107
101
  }),
108
102
  },
109
103
  ],
@@ -35,10 +35,8 @@ export const SUPPORTED_APIS = {
35
35
  vault: { id: 'vault', version: 'v1' },
36
36
  workspaceevents: { id: 'workspaceevents', version: 'v1' },
37
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.
38
+ // Names agents actually type for an API, mapped to real SUPPORTED_APIS keys;
39
+ // plural-target entries only where the split ("analytics" is two APIs) is real.
42
40
  export const API_ALIASES = {
43
41
  analytics: ['analyticsadmin', 'analyticsdata'],
44
42
  ga4: ['analyticsadmin', 'analyticsdata'],
@@ -74,9 +72,8 @@ export function discoveryCacheDir(env = process.env) {
74
72
  return (env.DISCOVERY_CACHE_PATH ??
75
73
  path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'mcp-google-multi', 'discovery'));
76
74
  }
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.
75
+ // Default cache path only (an operator's DISCOVERY_CACHE_PATH may be shared
76
+ // and keeps its mode), only when this process owns it; clears group/other bits.
80
77
  function tightenOwnDir(dir) {
81
78
  if (process.platform === 'win32')
82
79
  return;
@@ -110,9 +107,8 @@ export function buildMethodIndex(doc, api) {
110
107
  if (!method.id || !method.httpMethod || !method.path)
111
108
  continue;
112
109
  const params = method.parameters ?? {};
113
- // Google serves parameters{} with unstable key order between fetches;
114
- // parameterOrder is the canonical sequence, so sort by it (alphabetical
115
- // tie-break) to keep gen-tools output deterministic.
110
+ // parameters{} has unstable key order between fetches; sort by the
111
+ // canonical parameterOrder (alphabetical tie-break) for deterministic output.
116
112
  const order = method.parameterOrder ?? [];
117
113
  const pos = (n) => {
118
114
  const i = order.indexOf(n);
@@ -174,9 +170,8 @@ export async function loadMethodIndex(api, deps = {}) {
174
170
  if (cacheFresh())
175
171
  doc = readCache();
176
172
  if (!doc) {
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.
173
+ // Newer APIs are absent from the central discovery directory (404); try
174
+ // each service's own $discovery endpoint too, as scripts/fetch-discovery does.
180
175
  const urls = [
181
176
  `https://www.googleapis.com/discovery/v1/apis/${spec.id}/${spec.version}/rest`,
182
177
  `https://${spec.id}.googleapis.com/$discovery/rest?version=${spec.version}`,
@@ -217,20 +212,11 @@ export function clearDiscoveryMemoryCache() {
217
212
  // GA4-style report execution (runReport, batchRunPivotReports, runAccessReport)
218
213
  // and check* predicates are POSTs purely for the request-body size — reads.
219
214
  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`.
215
+ // Update, tested before delete, so cancelWipe (calling off a wipe) doesn't
216
+ // reach the delete list via its "cancel" suffix. See docs/internals.md.
227
217
  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.
218
+ // archive/wipe/reset/stop/cancel/end etc. all remove or permanently discard
219
+ // something that existed, so the most restrictive class is the safe one. See docs/internals.md.
234
220
  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;
235
221
  export function cudFromMethod(method) {
236
222
  switch (method.httpMethod) {
@@ -262,13 +248,8 @@ export function expandPath(template, pathParams) {
262
248
  if (value === undefined) {
263
249
  throw new Error(`Missing required path parameter "${name}"`);
264
250
  }
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.
251
+ // Both a blank segment and a "." / ".." segment silently retarget the
252
+ // request to a different, successfully-answered endpoint. See docs/internals.md.
272
253
  const segments = plus ? String(value).split('/') : [String(value)];
273
254
  for (const segment of segments) {
274
255
  if (segment.trim() === '') {
package/dist/doctor.d.ts CHANGED
@@ -52,10 +52,10 @@ export interface HttpProbeResult {
52
52
  unreachable?: boolean;
53
53
  problem?: string;
54
54
  }
55
- /** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
55
+ /** Console deep-link to enable one API (section-6 hint). */
56
56
  export declare function apiEnableLink(api: string): string;
57
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. */
58
+ * here and in `diagnose`. Read-only. */
59
59
  export declare function usageMetricsStatusLine(env: Record<string, string | undefined>): string;
60
60
  /** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
61
61
  export declare function overallVerdict(sections: DiagnosticSection[]): Verdict;
@@ -64,7 +64,7 @@ export declare function runDiagnostics(deps?: DiagnosticsDeps, opts?: {
64
64
  }): Promise<DiagnosticsReport>;
65
65
  /** doctor CLI (brew-doctor style): glyph-coded human report. */
66
66
  export declare function renderDoctorText(report: DiagnosticsReport): string;
67
- /** Redacted, paste-ready bug report (BR8): verdicts + provenance labels + token
67
+ /** Redacted, paste-ready bug report: verdicts + provenance labels + token
68
68
  * statuses + slugs; NO token values, secrets, keys, or full email addresses. */
69
69
  export declare function renderReport(report: DiagnosticsReport, version: string): string;
70
70
  /** Exit non-zero when any section FAILs; with strict, WARN counts too. */
package/dist/doctor.js CHANGED
@@ -35,9 +35,8 @@ const DEFAULT_DEPS = {
35
35
  probeApi: (alias) => probeApiEnablement(alias),
36
36
  probeHttp: (cfg) => probeHttpEndpoints(cfg),
37
37
  };
38
- /** §7 live check: the advertised OAuth metadata must derive from MCP_PUBLIC_URL
39
- * exactly — one mismatch between PRM `resource` / AS `issuer` and what clients
40
- * 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.
41
40
  async function probeHttpEndpoints(cfg) {
42
41
  try {
43
42
  const prmRes = await fetch(`${cfg.publicUrl}/.well-known/oauth-protected-resource`, {
@@ -66,7 +65,7 @@ async function probeHttpEndpoints(cfg) {
66
65
  return { ok: false, unreachable: true };
67
66
  }
68
67
  }
69
- /** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
68
+ /** Console deep-link to enable one API (section-6 hint). */
70
69
  export function apiEnableLink(api) {
71
70
  return `https://console.cloud.google.com/apis/library/${api}.googleapis.com`;
72
71
  }
@@ -75,8 +74,8 @@ function sectionRuntime(deps, tenantScope = false) {
75
74
  const major = Number.parseInt(deps.nodeVersion.split('.')[0] ?? '0', 10);
76
75
  if (!Number.isFinite(major) || major < MIN_NODE_MAJOR) {
77
76
  if (tenantScope) {
78
- // The host version is operator infrastructure, not the tenant's (E7
79
- // residue): no version string, and the fix is the operator's to apply.
77
+ // The host version is operator infrastructure, not the tenant's: no
78
+ // version string, and the fix is the operator's to apply.
80
79
  const rendered = hints()?.render({ kind: 'operator_only', slug: 'E_NODE_TOO_OLD' });
81
80
  return {
82
81
  id: 1,
@@ -150,7 +149,7 @@ function sectionConfig(deps, set, tenantScope = false) {
150
149
  return { id: 2, title: 'Config', verdict, lines, ...(hint ? { hint } : {}), ...(slug ? { slug } : {}) };
151
150
  }
152
151
  /** One line, state AND source, so the people being measured can see both
153
- * here and in `diagnose` (metrics spec section 2). Read-only. */
152
+ * here and in `diagnose`. Read-only. */
154
153
  export function usageMetricsStatusLine(env) {
155
154
  const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
156
155
  let configValue;
@@ -240,8 +239,7 @@ async function sectionApiEnablement(deps, aliases, tenantScope = false) {
240
239
  }
241
240
  catch (e) {
242
241
  // Network / transient: WARN with the target, never crash the report. The
243
- // raw text can name an egress or proxy host (E7 residue): tenant scope
244
- // drops it.
242
+ // raw text can name an egress or proxy host: tenant scope drops it.
245
243
  return {
246
244
  id: 6,
247
245
  title: 'API enablement',
@@ -338,10 +336,8 @@ export function overallVerdict(sections) {
338
336
  return worst;
339
337
  }
340
338
  export async function runDiagnostics(deps = DEFAULT_DEPS, opts = {}) {
341
- // Tenant scope omits the OPERATOR sections: §3 (key provenance = box
342
- // infrastructure) and §7 (its owner-gate lines print MCP_OWNER_EMAILS
343
- // values — other people's addresses from a tenant's seat), and trims §2 to
344
- // the context's own account count.
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.
345
341
  const tenantScope = opts.scope === 'tenant';
346
342
  const sections = [];
347
343
  sections.push(sectionRuntime(deps, tenantScope));
@@ -381,10 +377,8 @@ export function renderDoctorText(report) {
381
377
  }
382
378
  const EMAIL_LOCAL_CHAR = /[A-Za-z0-9._%+-]/;
383
379
  const EMAIL_DOMAIN_CHAR = /[A-Za-z0-9.-]/;
384
- /** Mask the local-part of an email so a report never carries a full address
385
- * (BR8). A linear scan, not a regex: the `local* @domain` pattern backtracks
386
- * polynomially on long local-charset runs without an @ (CodeQL
387
- * js/polynomial-redos), and report text can embed arbitrary error strings. */
380
+ // A linear scan, not a regex: `local*@domain` backtracks polynomially on long
381
+ // local-charset runs without an @ (CodeQL js/polynomial-redos).
388
382
  function maskEmails(text) {
389
383
  let out = '';
390
384
  let i = 0;
@@ -409,7 +403,7 @@ function maskEmails(text) {
409
403
  }
410
404
  return out;
411
405
  }
412
- /** Redacted, paste-ready bug report (BR8): verdicts + provenance labels + token
406
+ /** Redacted, paste-ready bug report: verdicts + provenance labels + token
413
407
  * statuses + slugs; NO token values, secrets, keys, or full email addresses. */
414
408
  export function renderReport(report, version) {
415
409
  const out = [`mcp-google-multi doctor report (v${version})`, `overall: ${report.verdict}`, ''];
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]);
package/dist/executor.js CHANGED
@@ -91,10 +91,8 @@ export async function executeApiMethod(method, args, deps = {}) {
91
91
  });
92
92
  const text = JSON.stringify(res.data ?? null);
93
93
  if (text.length > MAX_RESPONSE_CHARS) {
94
- // Budget the ENCODED head: jsonResult re-stringifies it, so every quote
95
- // and backslash inside doubles. Slicing raw characters made the emitted
96
- // result exceed the cap this tool advertises, by up to 15 percent on a
97
- // 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.
98
96
  return jsonResult({
99
97
  truncated: true,
100
98
  totalChars: text.length,
package/dist/fanout.js CHANGED
@@ -4,19 +4,13 @@ import { echoName, echoNames, MAX_SUGGESTED_KEYS } from './arg-strict.js';
4
4
  import { hints } from './hint-provider.js';
5
5
  export const CSV_RE = /^[a-zA-Z0-9_-]+(\s*,\s*[a-zA-Z0-9_-]+)+$/;
6
6
  const FANOUT_CONCURRENCY = 5;
7
- // `aliases` is required (no global default): the field is baked into a
8
- // registry's inputSchema, so it must read THAT registry's account set or a
9
- // '*' would advertise and accept another registry's aliases. It is a getter:
10
- // the set is read at parse time, so an alias added mid-session (account_add,
11
- // a tenant link) is valid on tools registered before it existed.
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.
12
9
  export function fanoutAccountField(description, aliases) {
13
10
  const boot = aliases();
14
11
  const csvExample = boot.length > 1 ? `; or a CSV subset like "${boot.slice(0, 2).join(',')}"` : '';
15
- // The union's OWN error only fires when every branch aborts at the type
16
- // check (a non-string). For a string, zod surfaces the single non-aborted
17
- // branch verbatim, which is the CSV regex, so a mistyped alias used to read
18
- // as "Invalid string: must match pattern /^[a-zA-Z0-9_-]+(,...)/" and never
19
- // named an alias. Hence the same message on BOTH.
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.
20
14
  const bad = () => unknownAliasMessage(aliases(), true);
21
15
  return z
22
16
  // The selector branch stays first: tools/list injects the live
@@ -46,9 +40,8 @@ export function noAccountsResult() {
46
40
  export function parseAccountSelector(value, accounts = getAccountSet().aliases) {
47
41
  if (value === '*')
48
42
  return { ok: true, fanout: true, aliases: [...accounts] };
49
- // A single alias used to skip validation entirely and reach getClient, whose
50
- // throw landed on the mapper's floor as an unclassified upstream_error. Same
51
- // 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.
52
45
  if (!value.includes(',')) {
53
46
  return accounts.includes(value)
54
47
  ? { ok: true, fanout: false, aliases: [value] }
@@ -132,10 +125,8 @@ export async function runFanout(handler, args, aliases) {
132
125
  await Promise.all(Array.from({ length: Math.min(FANOUT_CONCURRENCY, aliases.length) }, () => worker()));
133
126
  const failed = results.filter((r) => !r.ok).length;
134
127
  if (failed === results.length && results.length > 0) {
135
- // Every account failed, so this IS an error result, and used to carry
136
- // `isError` with no top-level error, hint or retriable: the agent saw a
137
- // successful-looking object flagged as an error, and metrics bucketed it
138
- // 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.
139
130
  const slugs = [...new Set(results.map((r) => r.error?.error ?? 'internal'))];
140
131
  const hints = [...new Set(results.map((r) => r.error?.hint).filter(Boolean))];
141
132
  const retriable = results.every((r) => r.error?.retriable === true);
@@ -1,6 +1,3 @@
1
- /** Which process instance holds a lock: PID namespace id, start time in
2
- * clock ticks since boot, and a boot id prefix. A part this process cannot
3
- * read is `host` or `0` (all three without /proc). */
4
1
  interface LockIdentity {
5
2
  ns: string;
6
3
  start: string;
package/dist/fs-atomic.js CHANGED
@@ -10,6 +10,9 @@ const RENAME_RETRY_MS = 20;
10
10
  const LOCK_LEASE_MS = 60_000;
11
11
  // Breaking a lock takes microseconds, so an older breaker file was left by a crash.
12
12
  const BREAKER_STALE_MS = 2_000;
13
+ // The no-hard-link fallback writes the lock in two syscalls (create, then the
14
+ // pid); a reader can catch it empty in between. See docs/internals.md.
15
+ const LOCK_WRITE_GRACE_MS = 2_000;
13
16
  const PROCESS_START_MS = performance.timeOrigin;
14
17
  const OWNER_NAME = /^(\d+)\.(\d+|host)\.(\d+)\.([0-9a-f]{8}|0)\.[0-9a-f]{12}\.owner$/;
15
18
  function startOf(pid) {
@@ -63,11 +66,19 @@ function sameFile(a, b) {
63
66
  function isEnoent(error) {
64
67
  return error.code === 'ENOENT';
65
68
  }
69
+ function bindingRecord(id) {
70
+ return `${id.dev}:${id.ino}:${id.mtimeNs}`;
71
+ }
72
+ function matchesBinding(content, id) {
73
+ const m = /^(\d+):(\d+):(\d+)$/.exec(content);
74
+ return m !== null && BigInt(m[1]) === id.dev && BigInt(m[2]) === id.ino && BigInt(m[3]) === id.mtimeNs;
75
+ }
66
76
  // Undefined when the lock changed hands while it was being read.
67
77
  function readHolder(lock) {
68
78
  const before = fs.lstatSync(lock, { bigint: true });
69
79
  const body = before.isFile() ? fs.readFileSync(lock, 'utf8') : '';
70
80
  let owner;
81
+ let ownerPending = false;
71
82
  if (before.isFile()) {
72
83
  const dir = path.dirname(lock);
73
84
  const prefix = `${path.basename(lock)}.`;
@@ -79,10 +90,12 @@ function readHolder(lock) {
79
90
  continue;
80
91
  const ownerPath = path.join(dir, name);
81
92
  try {
82
- if (sameFile(fs.lstatSync(ownerPath, { bigint: true }), before)) {
93
+ const ownerStat = fs.lstatSync(ownerPath, { bigint: true });
94
+ if (sameFile(ownerStat, before) || matchesBinding(fs.readFileSync(ownerPath, 'utf8'), before)) {
83
95
  owner = { path: ownerPath, ns: m[2], start: m[3], boot: m[4] };
84
96
  break;
85
97
  }
98
+ ownerPending = true;
86
99
  }
87
100
  catch (error) {
88
101
  if (!isEnoent(error))
@@ -93,7 +106,7 @@ function readHolder(lock) {
93
106
  const after = fs.lstatSync(lock, { bigint: true });
94
107
  if (!sameFile(before, after))
95
108
  return undefined;
96
- return { body, ino: after.ino, dev: after.dev, mtimeNs: after.mtimeNs, mtimeMs: Number(after.mtimeNs) / 1e6, owner };
109
+ return { body, ino: after.ino, dev: after.dev, mtimeNs: after.mtimeNs, mtimeMs: Number(after.mtimeNs) / 1e6, owner, ownerPending };
97
110
  }
98
111
  // The owner file's random name tells two holders apart even when the second
99
112
  // lock reuses the first one's freed inode.
@@ -115,10 +128,21 @@ function probeDead(pid) {
115
128
  return false;
116
129
  }
117
130
  }
131
+ // Symmetric: a clock step back must not make a write-in-progress lock look
132
+ // permanently fresh (see docs/internals.md).
133
+ function withinWriteGrace(now, mtimeMs) {
134
+ return Math.abs(now - mtimeMs) < LOCK_WRITE_GRACE_MS;
135
+ }
118
136
  function holderDead(holder, now) {
137
+ if (holder.body === '' && withinWriteGrace(now, holder.mtimeMs))
138
+ return false;
119
139
  const pid = Number(holder.body);
120
140
  if (!(Number.isSafeInteger(pid) && pid > 0))
121
141
  return true;
142
+ // A same-pid owner file exists but is not yet bound (the fallback lock's two
143
+ // writes race a reader); give it the same grace as an empty lock.
144
+ if (!holder.owner && holder.ownerPending && withinWriteGrace(now, holder.mtimeMs))
145
+ return false;
122
146
  const leaseOver = now - holder.mtimeMs >= LOCK_LEASE_MS;
123
147
  const owner = holder.owner;
124
148
  // No owner file: an older writer, whose namespace is unknown.
@@ -129,9 +153,8 @@ function holderDead(holder, now) {
129
153
  // Two unreadable parts compare equal without naming the same namespace or
130
154
  // boot, so only a fully known identity is looked up by start time.
131
155
  if (SELF.ns !== 'host' && SELF.boot !== '0' && SELF.start !== '0' && owner.start !== '0') {
132
- // A live namespace id is never shared, so the recorded pid is ours to
133
- // look up: a restarted container reuses both the id and the pid, but not
134
- // the start time.
156
+ // A live namespace id is never shared, so the recorded pid is ours to look
157
+ // up: a restarted container reuses both the id and the pid, but not the start time.
135
158
  const start = startOf(pid);
136
159
  if (start !== undefined)
137
160
  return start !== owner.start;
@@ -141,9 +164,8 @@ function holderDead(holder, now) {
141
164
  return holder.mtimeMs < PROCESS_START_MS;
142
165
  return probeDead(pid) || leaseOver;
143
166
  }
144
- // One waiter at a time breaks a dead lock: two breakers could otherwise both
145
- // see the dead holder, and the second would unlink the first one's new lock.
146
- // False when another waiter is breaking it.
167
+ // One waiter at a time breaks a dead lock, or the second could unlink the
168
+ // first one's new lock; false when another waiter is already breaking it.
147
169
  function breakLock(lock, holder) {
148
170
  const breaker = `${lock}.break`;
149
171
  // A token, not the inode: a breaker created after ours was removed can
@@ -198,10 +220,55 @@ function releaseBreaker(breaker, token) {
198
220
  throw error;
199
221
  }
200
222
  }
201
- // Hardlink-based advisory lock, generalized path-keyed from the token store's
202
- // alias-keyed original. The lock body is the bare pid, as older readers expect;
203
- // the owner file stays linked while held and names the holder's process
204
- // instance. See docs/internals.md.
223
+ // No hard links here (Android/Termux denies link() in private storage, some
224
+ // filesystems lack it entirely): the lock is created directly with O_EXCL,
225
+ // carrying the bare pid a linked owner file would carry, and the owner file
226
+ // is bound to it by a recorded identity instead of a hard link. See docs/internals.md.
227
+ function takeFallbackLock(lock, ownerFile) {
228
+ const fd = fs.openSync(lock, 'wx', 0o600);
229
+ let acquired;
230
+ try {
231
+ fs.writeSync(fd, String(process.pid));
232
+ const stat = fs.fstatSync(fd, { bigint: true });
233
+ acquired = { dev: stat.dev, ino: stat.ino, mtimeNs: stat.mtimeNs };
234
+ }
235
+ catch (error) {
236
+ // The lock is left empty; remove it only if it is still the one we just
237
+ // created, then rethrow so the caller never retries a write failure.
238
+ const stat = fs.fstatSync(fd, { bigint: true });
239
+ fs.closeSync(fd);
240
+ try {
241
+ if (sameFile(fs.lstatSync(lock, { bigint: true }), stat))
242
+ fs.rmSync(lock, { force: true });
243
+ }
244
+ catch (cleanupError) {
245
+ if (!isEnoent(cleanupError))
246
+ throw cleanupError;
247
+ }
248
+ throw error;
249
+ }
250
+ fs.closeSync(fd);
251
+ try {
252
+ fs.writeFileSync(ownerFile, bindingRecord(acquired));
253
+ }
254
+ catch (error) {
255
+ // The lock already exists holding our live pid; without this cleanup
256
+ // every other writer sees an ownerless holder and waits out the full lease.
257
+ try {
258
+ const current = fs.lstatSync(lock, { bigint: true });
259
+ if (sameFile(current, acquired) && current.mtimeNs === acquired.mtimeNs)
260
+ fs.rmSync(lock, { force: true });
261
+ }
262
+ catch (cleanupError) {
263
+ if (!isEnoent(cleanupError))
264
+ throw cleanupError;
265
+ }
266
+ throw error;
267
+ }
268
+ return acquired;
269
+ }
270
+ // Hardlink-based advisory lock; the lock body is the bare pid, as older
271
+ // readers expect. See docs/internals.md.
205
272
  export function withFileLock(absPath, fn, label = `lock: ${absPath}`) {
206
273
  const dir = path.dirname(absPath);
207
274
  const lock = path.join(dir, `.${path.basename(absPath)}.lock`);
@@ -210,17 +277,31 @@ export function withFileLock(absPath, fn, label = `lock: ${absPath}`) {
210
277
  fs.writeFileSync(ownerFile, String(process.pid), { mode: 0o600, flag: 'wx' });
211
278
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
212
279
  let linked = false;
280
+ let acquired;
213
281
  try {
214
282
  while (true) {
215
283
  try {
216
284
  fs.linkSync(ownerFile, lock);
217
285
  linked = true;
286
+ acquired = fs.lstatSync(lock, { bigint: true });
218
287
  break;
219
288
  }
220
289
  catch (error) {
221
290
  const err = error;
222
- if (err.code !== 'EEXIST')
291
+ if (err.code === 'EACCES' || err.code === 'EPERM') {
292
+ try {
293
+ acquired = takeFallbackLock(lock, ownerFile);
294
+ linked = true;
295
+ break;
296
+ }
297
+ catch (fbError) {
298
+ if (fbError.code !== 'EEXIST')
299
+ throw fbError;
300
+ }
301
+ }
302
+ else if (err.code !== 'EEXIST') {
223
303
  throw error;
304
+ }
224
305
  try {
225
306
  const holder = readHolder(lock);
226
307
  if (holder && holderDead(holder, Date.now()) && breakLock(lock, holder))
@@ -242,23 +323,24 @@ export function withFileLock(absPath, fn, label = `lock: ${absPath}`) {
242
323
  if (!linked)
243
324
  fs.rmSync(ownerFile, { force: true });
244
325
  }
245
- let bound = false;
246
326
  try {
247
- // Some network and FUSE filesystems give each name of one file its own
248
- // inode number; there the owner file cannot vouch for the lock.
249
- bound = sameFile(fs.lstatSync(ownerFile, { bigint: true }), fs.lstatSync(lock, { bigint: true }));
250
327
  return fn();
251
328
  }
252
329
  finally {
253
- releaseLock(lock, ownerFile, bound);
330
+ releaseLock(lock, ownerFile, acquired);
254
331
  }
255
332
  }
256
333
  // A holder whose lock was broken (its owner file removed with it) must not
257
- // remove its successor's lock.
258
- function releaseLock(lock, ownerFile, bound) {
334
+ // remove its successor's lock: only drop the lock if it is still the exact
335
+ // file this call created, and our own owner file is still there to vouch for it.
336
+ function releaseLock(lock, ownerFile, acquired) {
259
337
  try {
260
- if (!bound || sameFile(fs.lstatSync(ownerFile, { bigint: true }), fs.lstatSync(lock, { bigint: true })))
261
- fs.rmSync(lock, { force: true });
338
+ if (acquired) {
339
+ const current = fs.lstatSync(lock, { bigint: true });
340
+ if (sameFile(current, acquired) && current.mtimeNs === acquired.mtimeNs && fs.existsSync(ownerFile)) {
341
+ fs.rmSync(lock, { force: true });
342
+ }
343
+ }
262
344
  }
263
345
  catch (error) {
264
346
  if (!isEnoent(error))
@@ -58,7 +58,10 @@ export type HintCase = {
58
58
  entries: string[];
59
59
  } | {
60
60
  kind: 'no_default_account';
61
- aliases: string[];
61
+ accounts: {
62
+ alias: string;
63
+ email: string;
64
+ }[];
62
65
  } | {
63
66
  kind: 'no_accounts';
64
67
  } | {