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
@@ -0,0 +1,109 @@
1
+ // Registry composition, extracted from index.ts so tests can build registries
2
+ // without touching the CLI entrypoint. No import-time side effects of its own.
3
+ import { McpServer } from "@modelcontextprotocol/server";
4
+ import { GENERATED_SERVICES } from './tools/generated/index.js';
5
+ import { GENERATED_GATES, SERVICES, servicesAwaitingRestart, unknownToolMessage } from './services.js';
6
+ import { argNormalizationEnabled } from './arg-normalize.js';
7
+ import { unknownArgMode } from './arg-strict.js';
8
+ import { ToolRegistry } from './registry.js';
9
+ import { registerDiscoverTools } from './discover.js';
10
+ import { registerEscapeTools } from './tools/google-api.js';
11
+ import { accountHealthDepsFor, registerAccountTools } from './tools/accounts-tool.js';
12
+ import { registerDiagnoseTool } from './doctor.js';
13
+ import { registerAccountWizardTools } from './tools/account-wizard.js';
14
+ import { getToolsets, toolsetEnabled } from './toolsets.js';
15
+ import { isOwnerContext } from './identity.js';
16
+ import { resolveScopesForAccount } from './auth.js';
17
+ /** The one place the MCP server is constructed, so an embedding host can pass
18
+ * per-session `instructions` without re-deriving the server identity. */
19
+ export function createMcpServer(version, opts = {}) {
20
+ const instructions = opts.instructions?.trim() ? opts.instructions : undefined;
21
+ return new McpServer({ name: 'mcp-google-multi', version }, instructions ? { instructions } : undefined);
22
+ }
23
+ export function buildRegistry(server, ctx, mode, metrics = null) {
24
+ const policy = ctx.policy;
25
+ // The registry's account view IS the context's, not the process global.
26
+ const registry = new ToolRegistry(server, policy, mode, metrics, () => ctx.accounts);
27
+ // ctx.accounts is read per call, never snapshotted here, so a live getter stays live.
28
+ const owner = isOwnerContext(ctx);
29
+ const clientDeps = { getClientFn: ctx.getClient, localFiles: owner };
30
+ const execDeps = {
31
+ getClientFn: ctx.getClient,
32
+ scopeDeps: {
33
+ readTokenFn: ctx.tokenStore.readToken,
34
+ profileFn: (alias) => resolveScopesForAccount(alias, ctx.accounts),
35
+ },
36
+ };
37
+ const toolsets = getToolsets();
38
+ if (toolsets !== 'all') {
39
+ const known = new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)]);
40
+ for (const requested of toolsets) {
41
+ if (!known.has(requested)) {
42
+ process.stderr.write(`GOOGLE_TOOLSETS: unknown service "${requested}" ignored\n`);
43
+ }
44
+ }
45
+ }
46
+ for (const svc of SERVICES) {
47
+ if (!toolsetEnabled(toolsets, svc.name))
48
+ continue;
49
+ if (svc.enabled && !svc.enabled(ctx.accounts)) {
50
+ if (toolsets !== 'all') {
51
+ const hint = svc.name === 'admin' ? 'set admin on an account/profile (or GOOGLE_ADMIN_ACCOUNTS)' : `add "${svc.name}" to an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES)`;
52
+ process.stderr.write(`GOOGLE_TOOLSETS: "${svc.name}" requested but not enabled — ${hint}\n`);
53
+ }
54
+ continue;
55
+ }
56
+ svc.register(registry, clientDeps);
57
+ }
58
+ for (const gen of GENERATED_SERVICES) {
59
+ if (!toolsetEnabled(toolsets, gen.name))
60
+ continue;
61
+ const curated = SERVICES.find((s) => s.name === gen.name);
62
+ const gate = curated?.enabled ?? GENERATED_GATES[gen.name]?.enabled;
63
+ if (gate && !gate(ctx.accounts)) {
64
+ if (!curated && toolsets !== 'all') {
65
+ process.stderr.write(`GOOGLE_TOOLSETS: "${gen.name}" requested but not enabled — ${GENERATED_GATES[gen.name].hint}\n`);
66
+ }
67
+ continue;
68
+ }
69
+ gen.register(registry, execDeps);
70
+ }
71
+ if (registry.services().length === 0) {
72
+ const known = [...new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)])].sort();
73
+ throw new Error(`GOOGLE_TOOLSETS="${process.env.GOOGLE_TOOLSETS ?? ''}" selected no enabled services. ` +
74
+ `Known services: ${known.join(', ')}. ` +
75
+ `Note: optional services need their bundle in an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES); admin needs an admin account/profile.`);
76
+ }
77
+ registerDiscoverTools(registry, policy);
78
+ registerEscapeTools(registry, policy, execDeps);
79
+ registerAccountTools(registry, accountHealthDepsFor(ctx.tokenStore));
80
+ registerDiagnoseTool(registry, ctx);
81
+ // The wizard edits the owner's config.json and token store; any other
82
+ // context has neither.
83
+ if (owner)
84
+ registerAccountWizardTools(registry, server, () => servicesAwaitingRestart(registry));
85
+ return registry;
86
+ }
87
+ /** The per-request hooks bound to one registry: argument shapes, unknown-
88
+ * argument screening and the account a validation envelope names. Both
89
+ * transports and every resolved server target build them here, so a mistyped
90
+ * argument behaves the same wherever it arrives. */
91
+ export function requestHooksFor(registry, ctx, metrics = null, env = process.env) {
92
+ const mode = unknownArgMode(env);
93
+ return {
94
+ argShapeFor: argNormalizationEnabled(env) ? (tool) => registry.argShape(tool) : undefined,
95
+ strictArgs: mode === 'off'
96
+ ? undefined
97
+ : {
98
+ mode,
99
+ declaredFor: (tool) => registry.declaredKeys(tool),
100
+ siblingsFor: (tool, keys) => registry.siblingSpellings(tool, keys),
101
+ unknownTool: (name) => unknownToolMessage(registry, name),
102
+ onDrop: metrics ? (tool, keys) => metrics.recordArgDrop(tool, keys) : undefined,
103
+ },
104
+ validationEnvelope: {
105
+ isKnownTool: (name) => registry.hasTool(name),
106
+ defaultAccount: () => ctx.accounts.defaultAccount,
107
+ },
108
+ };
109
+ }
@@ -1,7 +1,20 @@
1
1
  export declare const CONFIG_VERSION = 1;
2
2
  export declare function configDir(env?: NodeJS.ProcessEnv): string;
3
3
  export declare function configFilePath(env?: NodeJS.ProcessEnv): string;
4
+ export declare function tenantsDir(env?: NodeJS.ProcessEnv): string;
5
+ export declare function tenantConfigFilePath(tenantId: string, env?: NodeJS.ProcessEnv): string;
6
+ export declare function tenantTokenDir(tenantId: string, env?: NodeJS.ProcessEnv): string;
7
+ /** Create and permission-harden the tenant directory chain. mkdirSync's
8
+ * recursive mode only stamps the DEEPEST newly-created dir, so every level
9
+ * gets an explicit chmod: the shared tenants/ root (created by whichever
10
+ * tenant provisions first) must never stay group-readable. */
11
+ export declare function ensureTenantDirs(tenantId: string, env?: NodeJS.ProcessEnv): {
12
+ configFile: string;
13
+ tokenDir: string;
14
+ };
4
15
  export declare const ALIAS_RE: RegExp;
16
+ export declare const RESERVED_ALIASES: string[];
17
+ export declare function isReservedAlias(name: string): boolean;
5
18
  export interface ConfigFile {
6
19
  version: number;
7
20
  accounts?: Record<string, {
@@ -10,6 +10,38 @@ export function configDir(env = process.env) {
10
10
  export function configFilePath(env = process.env) {
11
11
  return path.join(configDir(env), 'config.json');
12
12
  }
13
+ export function tenantsDir(env = process.env) {
14
+ return path.join(configDir(env), 'tenants');
15
+ }
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.
18
+ function assertTenantId(tenantId) {
19
+ if (!ALIAS_RE.test(tenantId) || isReservedAlias(tenantId) || tenantId === 'owner') {
20
+ throw new Error(`E_TENANT_ID_INVALID: invalid tenant id "${tenantId}".`);
21
+ }
22
+ }
23
+ export function tenantConfigFilePath(tenantId, env = process.env) {
24
+ assertTenantId(tenantId);
25
+ return path.join(tenantsDir(env), tenantId, 'config.json');
26
+ }
27
+ export function tenantTokenDir(tenantId, env = process.env) {
28
+ assertTenantId(tenantId);
29
+ return path.join(tenantsDir(env), tenantId, 'tokens');
30
+ }
31
+ /** Create and permission-harden the tenant directory chain. mkdirSync's
32
+ * recursive mode only stamps the DEEPEST newly-created dir, so every level
33
+ * gets an explicit chmod: the shared tenants/ root (created by whichever
34
+ * tenant provisions first) must never stay group-readable. */
35
+ export function ensureTenantDirs(tenantId, env = process.env) {
36
+ const tokenDir = tenantTokenDir(tenantId, env);
37
+ const levels = [configDir(env), tenantsDir(env), path.join(tenantsDir(env), tenantId), tokenDir];
38
+ for (const dir of levels) {
39
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
40
+ if (process.platform !== 'win32')
41
+ fs.chmodSync(dir, 0o700);
42
+ }
43
+ return { configFile: tenantConfigFilePath(tenantId, env), tokenDir };
44
+ }
13
45
  // Alias charset is load-bearing security, not cosmetics: the alias becomes a
14
46
  // tokenDir path segment (encPath), so this guard blocks path traversal.
15
47
  export const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
@@ -19,10 +51,11 @@ const accountEntrySchema = z.strictObject({
19
51
  admin: z.boolean().optional(),
20
52
  });
21
53
  // .strict() everywhere: any unknown or secret-shaped key (clientSecret,
22
- // masterKey, jwtKey, clientId) fails validation, structurally enforcing
23
- // secrets-env-only. scopeProfiles/defaultAccount/discovery/toolsets are part
24
- // of the frozen envelope; their consumers land in later slices.
25
- const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
54
+ // masterKey, jwtKey, clientId) fails validation, structurally enforcing secrets-env-only.
55
+ export const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
56
+ export function isReservedAlias(name) {
57
+ return RESERVED_ALIASES.includes(name);
58
+ }
26
59
  const configSchema = z.strictObject({
27
60
  version: z.number().int(),
28
61
  accounts: z.record(z.string().regex(ALIAS_RE), accountEntrySchema).optional(),
@@ -36,9 +69,8 @@ const configSchema = z.strictObject({
36
69
  defaultAccount: z.string().optional(),
37
70
  discovery: z.enum(['lazy', 'curated', 'eager']).optional(),
38
71
  toolsets: z.string().optional(),
39
- // Local usage metrics (metrics-feature-spec): absent = off. Downgrade rule:
40
- // a pre-6.0 build reading a config carrying this key fails E_CONFIG_INVALID
41
- // (strictObject); remove the key first.
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.
42
74
  usageMetrics: z.boolean().optional(),
43
75
  });
44
76
  export function failStartup(slug, message) {
@@ -89,9 +121,8 @@ export function loadConfigFile(filePath = configFilePath(), onInvalid = 'exit')
89
121
  const issue = result.error.issues[0];
90
122
  fail('E_CONFIG_INVALID', `${filePath}: ${issue.path.join('.') || '(root)'}: ${issue.message}. Secrets never belong in config.json.`);
91
123
  }
92
- // Post-schema because zod's key regex passes these; a __proto__ record key
93
- // is a prototype-pollution foothold, and Object.prototype member names break
94
- // 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.
95
126
  const data = result.data;
96
127
  for (const [section, keys] of [
97
128
  ['accounts', Object.keys(data.accounts ?? {})],
package/dist/discover.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { describePolicy } from './write-control.js';
3
- // The description budget is the whole point of lazy mode: a full op list per
4
- // service put lazy tools/list at ~11.5k tokens (RQ2), so descriptions carry a
5
- // CAPPED vocabulary and the complete catalog stays in this tool's RESULT.
3
+ import { hints } from './hint-provider.js';
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.
6
6
  const CURATED_OPS_CAP = 10;
7
7
  const GENERATED_GROUPS_CAP = 6;
8
8
  function opVocabulary(registry, service) {
@@ -14,8 +14,7 @@ function opVocabulary(registry, service) {
14
14
  parts.push(`${shown.join(', ')}${more > 0 ? ` +${more} more` : ''}`);
15
15
  }
16
16
  if (generated.length > 0) {
17
- // The generated long tail compresses to its resource groups: denser and
18
- // more selective than any truncated name list.
17
+ // Compresses to resource groups: denser than any truncated name list.
19
18
  const counts = new Map();
20
19
  for (const op of generated) {
21
20
  const group = op.split('_')[0];
@@ -30,10 +29,7 @@ function opVocabulary(registry, service) {
30
29
  }
31
30
  export function registerDiscoverTools(registry, policy) {
32
31
  const registerMeta = registry.registerMeta;
33
- // Agent-controllable runtime expansion (D3 refinement): reveal the whole
34
- // curated quality layer for heavy Google work, collapse to reclaim the
35
- // name/schema context budget after. stdio-only semantics — over stateless
36
- // 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.
37
33
  registerMeta('discover_all', {
38
34
  description: 'Reveal ALL curated Google tools at once (instead of per-service discovery). Use when ' +
39
35
  'starting substantial Google work; prefer these over google_api_call. Pair with discover_reset.',
@@ -91,6 +87,7 @@ export function registerDiscoverTools(registry, policy) {
91
87
  }, async ({ query }) => {
92
88
  const operations = registry.catalog(service, query);
93
89
  registry.reveal(service);
90
+ const rendered = hints()?.render({ kind: 'policy_summary', policy });
94
91
  return {
95
92
  content: [
96
93
  {
@@ -98,7 +95,7 @@ export function registerDiscoverTools(registry, policy) {
98
95
  text: JSON.stringify({
99
96
  service,
100
97
  operations,
101
- writeControl: describePolicy(policy),
98
+ writeControl: rendered?.hint ?? describePolicy(policy),
102
99
  next: operations.length > 0
103
100
  ? 'Call the chosen tool by name; it is now listed and callable.'
104
101
  : `No ${service} operation matches "${query}". Call again without query for the full catalog.`,
@@ -49,5 +49,8 @@ export declare function loadMethodIndex(api: string, deps?: DiscoveryDeps): Prom
49
49
  export declare function clearDiscoveryMemoryCache(): void;
50
50
  export declare function cudFromMethod(method: Pick<DiscoveryMethod, 'httpMethod' | 'id'>): 'read' | 'create' | 'update' | 'delete';
51
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;
52
55
  export declare function searchMethods(index: DiscoveryMethod[], query: string, limit?: number): DiscoveryMethod[];
53
56
  export {};
@@ -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,6 +72,20 @@ 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
  }
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.
77
+ function tightenOwnDir(dir) {
78
+ if (process.platform === 'win32')
79
+ return;
80
+ try {
81
+ const st = fs.statSync(dir);
82
+ if (st.uid === process.getuid?.() && (st.mode & 0o077) !== 0)
83
+ fs.chmodSync(dir, st.mode & ~0o077 & 0o7777);
84
+ }
85
+ catch {
86
+ // the cache still works with the directory's own mode
87
+ }
88
+ }
77
89
  // A poisoned cache file could repoint baseUrl off-Google; refuse to send the
78
90
  // user's Bearer token anywhere but a googleapis.com host.
79
91
  export function isGoogleApiUrl(url) {
@@ -95,9 +107,8 @@ export function buildMethodIndex(doc, api) {
95
107
  if (!method.id || !method.httpMethod || !method.path)
96
108
  continue;
97
109
  const params = method.parameters ?? {};
98
- // Google serves parameters{} with unstable key order between fetches;
99
- // parameterOrder is the canonical sequence, so sort by it (alphabetical
100
- // 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.
101
112
  const order = method.parameterOrder ?? [];
102
113
  const pos = (n) => {
103
114
  const i = order.indexOf(n);
@@ -135,6 +146,7 @@ export async function loadMethodIndex(api, deps = {}) {
135
146
  throw new Error(`Unknown api "${api}". Known: ${Object.keys(SUPPORTED_APIS).join(', ')}`);
136
147
  }
137
148
  const fetchFn = deps.fetchFn ?? ((url) => fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }));
149
+ const defaultDir = deps.cacheDir === undefined && process.env.DISCOVERY_CACHE_PATH === undefined;
138
150
  const cacheDir = deps.cacheDir ?? discoveryCacheDir();
139
151
  const cacheFile = path.join(cacheDir, `${api}.json`);
140
152
  const readCache = () => {
@@ -158,9 +170,8 @@ export async function loadMethodIndex(api, deps = {}) {
158
170
  if (cacheFresh())
159
171
  doc = readCache();
160
172
  if (!doc) {
161
- // Newer APIs (analyticsadmin/analyticsdata) are absent from the central
162
- // discovery directory (404); each service's own $discovery endpoint is
163
- // authoritative, so try both — same fallback as scripts/fetch-discovery.
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.
164
175
  const urls = [
165
176
  `https://www.googleapis.com/discovery/v1/apis/${spec.id}/${spec.version}/rest`,
166
177
  `https://${spec.id}.googleapis.com/$discovery/rest?version=${spec.version}`,
@@ -172,7 +183,9 @@ export async function loadMethodIndex(api, deps = {}) {
172
183
  if (!res.ok)
173
184
  throw new Error(`HTTP ${res.status}`);
174
185
  doc = (await res.json());
175
- fs.mkdirSync(cacheDir, { recursive: true });
186
+ fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
187
+ if (defaultDir)
188
+ tightenOwnDir(cacheDir);
176
189
  fs.writeFileSync(cacheFile, JSON.stringify(doc), { mode: 0o600 });
177
190
  break;
178
191
  }
@@ -199,20 +212,11 @@ export function clearDiscoveryMemoryCache() {
199
212
  // GA4-style report execution (runReport, batchRunPivotReports, runAccessReport)
200
213
  // and check* predicates are POSTs purely for the request-body size — reads.
201
214
  const POST_READ_VERB = /^(get|list|search|query|lookup|count|batchGet|generateIds|export|download|inspect|check|suggest|(batch)?run\w*report)/i;
202
- // `cancelWipe` must be matched here, BEFORE `cancel` reaches the delete list:
203
- // it calls off a pending wipe, which is the opposite of one. Update is tested
204
- // before delete for exactly that reason.
205
- // State transitions on something that already exists: reversible, and none of
206
- // them creates anything, so `create` was both wrong and the most permissive
207
- // class available. `(batch)?` mirrors the delete list, without which every
208
- // `batchUpdate*` POST fell through to `create`.
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.
209
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;
210
- // archive sits with the deletes: in GA4 archiving a custom dimension/metric is
211
- // permanent, so the most restrictive write class is the safe classification.
212
- // So do the teardown verbs: `stop` and `cancel` tear down a push channel or a
213
- // long-running operation, `wipe`/`invalidate`/`signOut` destroy device data,
214
- // codes and sessions, `reset` discards a configuration, and `end` terminates a
215
- // live conference. Each removes something that existed.
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.
216
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;
217
221
  export function cudFromMethod(method) {
218
222
  switch (method.httpMethod) {
@@ -244,13 +248,8 @@ export function expandPath(template, pathParams) {
244
248
  if (value === undefined) {
245
249
  throw new Error(`Missing required path parameter "${name}"`);
246
250
  }
247
- // A blank segment collapses the path and Google routes the resulting
248
- // trailing-slash URL to the COLLECTION: `sites.get` with siteUrl:"" came
249
- // back as the whole site LIST, reported as success. Dot segments resolve
250
- // at URL-parse time, so `../v1/contactGroups` retargets the call at a
251
- // different endpoint and returns ITS response as success. Both are
252
- // silent-wrong, so refuse locally rather than send a different request.
253
- // `{+param}` keeps its slashes, so every segment has to be checked.
251
+ // Both a blank segment and a "." / ".." segment silently retarget the
252
+ // request to a different, successfully-answered endpoint. See docs/internals.md.
254
253
  const segments = plus ? String(value).split('/') : [String(value)];
255
254
  for (const segment of segments) {
256
255
  if (segment.trim() === '') {
@@ -263,8 +262,13 @@ export function expandPath(template, pathParams) {
263
262
  return segments.map(encodeURIComponent).join('/');
264
263
  });
265
264
  }
265
+ /** Search keywords are a few words; the caps keep a caller-sized query from
266
+ * driving index-size x token-count substring scans on the event loop. */
267
+ export const MAX_SEARCH_QUERY = 500;
268
+ const MAX_SEARCH_TOKENS = 16;
266
269
  export function searchMethods(index, query, limit = 10) {
267
- const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
270
+ const words = query.slice(0, MAX_SEARCH_QUERY).toLowerCase().split(/\s+/).filter(Boolean);
271
+ const tokens = [...new Set(words)].slice(0, MAX_SEARCH_TOKENS);
268
272
  if (tokens.length === 0)
269
273
  return index.slice(0, limit);
270
274
  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';
@@ -51,21 +52,24 @@ export interface HttpProbeResult {
51
52
  unreachable?: boolean;
52
53
  problem?: string;
53
54
  }
54
- /** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
55
+ /** Console deep-link to enable one API (section-6 hint). */
55
56
  export declare function apiEnableLink(api: string): string;
56
57
  /** One line, state AND source, so the people being measured can see both
57
- * here and in `diagnose` (metrics spec section 2). Read-only. */
58
+ * here and in `diagnose`. Read-only. */
58
59
  export declare function usageMetricsStatusLine(env: Record<string, string | undefined>): string;
59
60
  /** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
60
61
  export declare function overallVerdict(sections: DiagnosticSection[]): Verdict;
61
- export declare function runDiagnostics(deps?: DiagnosticsDeps): Promise<DiagnosticsReport>;
62
+ export declare function runDiagnostics(deps?: DiagnosticsDeps, opts?: {
63
+ scope?: 'operator' | 'tenant';
64
+ }): Promise<DiagnosticsReport>;
62
65
  /** doctor CLI (brew-doctor style): glyph-coded human report. */
63
66
  export declare function renderDoctorText(report: DiagnosticsReport): string;
64
- /** Redacted, paste-ready bug report (BR8): verdicts + provenance labels + token
67
+ /** Redacted, paste-ready bug report: verdicts + provenance labels + token
65
68
  * statuses + slugs; NO token values, secrets, keys, or full email addresses. */
66
69
  export declare function renderReport(report: DiagnosticsReport, version: string): string;
67
70
  /** Exit non-zero when any section FAILs; with strict, WARN counts too. */
68
71
  export declare function exitCodeFor(report: DiagnosticsReport, strict: boolean): number;
72
+ export type DiagnoseContext = Pick<IdentityContext, 'subject' | 'accounts' | 'getClient' | 'tokenStore'>;
69
73
  /** Agent-callable structured health report (read-only). Mirrors `doctor`'s
70
74
  * engine. Registered as a META tool, like `account_list`: it introspects this
71
75
  * server rather than Google data, and as a normal tool it became a service of
@@ -73,7 +77,7 @@ export declare function exitCodeFor(report: DiagnosticsReport, strict: boolean):
73
77
  * from already-registered services, and this runs after that), so it was
74
78
  * advertised only once something else expanded the surface. The README sends
75
79
  * people here when they are stuck, so it has to be findable. */
76
- export declare function registerDiagnoseTool(registry: ToolRegistry): void;
80
+ export declare function registerDiagnoseTool(registry: ToolRegistry, ctx?: DiagnoseContext): void;
77
81
  export interface ResetOptions {
78
82
  account?: string;
79
83
  regenerateKey: boolean;