mcp-google-multi 5.4.1-alpha.2 → 6.0.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +21 -16
  2. package/dist/accounts.d.ts +44 -5
  3. package/dist/accounts.js +267 -25
  4. package/dist/auth.d.ts +11 -4
  5. package/dist/auth.js +85 -78
  6. package/dist/client-config.d.ts +90 -0
  7. package/dist/client-config.js +282 -0
  8. package/dist/client.js +10 -4
  9. package/dist/config-file.d.ts +38 -0
  10. package/dist/config-file.js +117 -0
  11. package/dist/discover.js +57 -3
  12. package/dist/doctor.d.ts +86 -0
  13. package/dist/doctor.js +384 -0
  14. package/dist/env-load.d.ts +11 -0
  15. package/dist/env-load.js +52 -0
  16. package/dist/executor.d.ts +2 -0
  17. package/dist/executor.js +2 -1
  18. package/dist/fanout.js +8 -5
  19. package/dist/fs-atomic.d.ts +3 -0
  20. package/dist/fs-atomic.js +123 -0
  21. package/dist/http-config.d.ts +35 -0
  22. package/dist/http-config.js +113 -0
  23. package/dist/http-transport.d.ts +59 -0
  24. package/dist/http-transport.js +269 -0
  25. package/dist/identity.d.ts +24 -0
  26. package/dist/identity.js +19 -0
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +199 -18
  29. package/dist/master-key.d.ts +53 -0
  30. package/dist/master-key.js +242 -0
  31. package/dist/mcp-token.d.ts +81 -0
  32. package/dist/mcp-token.js +176 -0
  33. package/dist/migrate-config.d.ts +7 -0
  34. package/dist/migrate-config.js +103 -0
  35. package/dist/migrate-tokens.js +4 -3
  36. package/dist/net-tuning.d.ts +8 -0
  37. package/dist/net-tuning.js +25 -0
  38. package/dist/oauth-as.d.ts +52 -0
  39. package/dist/oauth-as.js +457 -0
  40. package/dist/oauth-consent.d.ts +28 -0
  41. package/dist/oauth-consent.js +109 -0
  42. package/dist/open-url.d.ts +1 -0
  43. package/dist/open-url.js +30 -0
  44. package/dist/reauth-hint.d.ts +2 -0
  45. package/dist/reauth-hint.js +15 -0
  46. package/dist/registry-transfer.d.ts +41 -0
  47. package/dist/registry-transfer.js +219 -0
  48. package/dist/registry.d.ts +27 -1
  49. package/dist/registry.js +117 -6
  50. package/dist/scope-catalog.d.ts +19 -0
  51. package/dist/scope-catalog.js +182 -0
  52. package/dist/scope-observability.d.ts +78 -0
  53. package/dist/scope-observability.js +192 -0
  54. package/dist/services.js +1 -1
  55. package/dist/setup-prompt.d.ts +9 -0
  56. package/dist/setup-prompt.js +59 -0
  57. package/dist/ssrf-guard.d.ts +48 -0
  58. package/dist/ssrf-guard.js +242 -0
  59. package/dist/token-store.js +23 -125
  60. package/dist/tools/_errors.d.ts +8 -2
  61. package/dist/tools/_errors.js +97 -6
  62. package/dist/tools/account-wizard.d.ts +33 -0
  63. package/dist/tools/account-wizard.js +284 -0
  64. package/dist/tools/accounts-tool.d.ts +8 -5
  65. package/dist/tools/accounts-tool.js +38 -15
  66. package/dist/tools/admin.js +2 -2
  67. package/dist/tools/calendar.js +2 -2
  68. package/dist/tools/chat.js +2 -2
  69. package/dist/tools/contacts.d.ts +40 -0
  70. package/dist/tools/contacts.js +135 -2
  71. package/dist/tools/docs.d.ts +3 -0
  72. package/dist/tools/docs.js +60 -9
  73. package/dist/tools/drive.d.ts +3 -0
  74. package/dist/tools/drive.js +60 -8
  75. package/dist/tools/forms.js +4 -2
  76. package/dist/tools/generated/_shared.d.ts +1 -3
  77. package/dist/tools/generated/_shared.js +3 -3
  78. package/dist/tools/generated/admin.js +175 -132
  79. package/dist/tools/generated/appsmarket.js +6 -2
  80. package/dist/tools/generated/calendar.js +45 -27
  81. package/dist/tools/generated/chat.js +81 -47
  82. package/dist/tools/generated/classroom.js +131 -104
  83. package/dist/tools/generated/cloudidentity.js +73 -61
  84. package/dist/tools/generated/cloudsearch.js +59 -49
  85. package/dist/tools/generated/contacts.js +22 -14
  86. package/dist/tools/generated/drive.js +51 -35
  87. package/dist/tools/generated/driveactivity.js +5 -1
  88. package/dist/tools/generated/drivelabels.js +30 -25
  89. package/dist/tools/generated/forms.js +7 -3
  90. package/dist/tools/generated/gmail.js +73 -58
  91. package/dist/tools/generated/groupsmigration.js +5 -1
  92. package/dist/tools/generated/groupssettings.js +7 -3
  93. package/dist/tools/generated/keep.js +12 -7
  94. package/dist/tools/generated/licensing.js +11 -7
  95. package/dist/tools/generated/meet.js +20 -13
  96. package/dist/tools/generated/postmaster.js +8 -4
  97. package/dist/tools/generated/reseller.js +22 -17
  98. package/dist/tools/generated/script.js +26 -16
  99. package/dist/tools/generated/sheets.js +11 -7
  100. package/dist/tools/generated/tasks.js +6 -2
  101. package/dist/tools/generated/vault.js +38 -33
  102. package/dist/tools/generated/workspaceevents.js +12 -7
  103. package/dist/tools/gmail-mime.d.ts +38 -8
  104. package/dist/tools/gmail-mime.js +75 -71
  105. package/dist/tools/gmail.d.ts +31 -0
  106. package/dist/tools/gmail.js +483 -99
  107. package/dist/tools/google-api.js +7 -4
  108. package/dist/tools/meet.js +2 -2
  109. package/dist/tools/searchconsole.js +2 -2
  110. package/dist/tools/sheets.js +6 -2
  111. package/dist/tools/slides.js +4 -2
  112. package/dist/tools/tasks.js +2 -2
  113. package/dist/types.d.ts +1 -1
  114. package/dist/write-control.d.ts +24 -1
  115. package/dist/write-control.js +26 -1
  116. package/package.json +26 -7
@@ -0,0 +1,30 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { release } from 'node:os';
3
+ const isWsl = () => process.platform === 'linux' && release().toLowerCase().includes('microsoft');
4
+ // Best-effort browser launch. Never throws, never blocks, writes nothing to
5
+ // the parent's stdio — the caller has already printed the URL as the fallback.
6
+ export function openUrl(url) {
7
+ try {
8
+ let cmd;
9
+ let args;
10
+ if (process.platform === 'darwin') {
11
+ cmd = 'open';
12
+ args = [url];
13
+ }
14
+ else if (process.platform === 'win32' || isWsl()) {
15
+ const encoded = Buffer.from(`Start-Process "${url}"`, 'utf16le').toString('base64');
16
+ cmd = 'powershell.exe';
17
+ args = ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded];
18
+ }
19
+ else {
20
+ cmd = 'xdg-open';
21
+ args = [url];
22
+ }
23
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
24
+ child.on('error', () => { });
25
+ child.unref();
26
+ }
27
+ catch {
28
+ // Headless/odd platforms: the printed URL is the supported path.
29
+ }
30
+ }
@@ -0,0 +1,2 @@
1
+ export declare function setHttpReauthBase(base: string | null): void;
2
+ export declare function reauthHint(account: string): string;
@@ -0,0 +1,15 @@
1
+ // B13 in-call re-auth (BV-1): a dead/missing per-account Google token is a
2
+ // leg-C failure. Over stdio the fix is the `auth --account` CLI; over HTTP the
3
+ // user cannot run a CLI, so the hint becomes a clickable link into the AS's
4
+ // clientless alias_reauth flow (${BASE}/authorize?flow=alias_reauth&alias=…).
5
+ // The HTTP bootstrap sets the base when the AS starts; stdio never does.
6
+ let httpReauthBase = null;
7
+ export function setHttpReauthBase(base) {
8
+ httpReauthBase = base;
9
+ }
10
+ export function reauthHint(account) {
11
+ if (httpReauthBase) {
12
+ return `Re-authenticate "${account}" in your browser, then retry: ${httpReauthBase}/authorize?flow=alias_reauth&alias=${encodeURIComponent(account)}`;
13
+ }
14
+ return `Run: npx mcp-google-multi auth --account ${account}`;
15
+ }
@@ -0,0 +1,41 @@
1
+ export interface TransferBundle {
2
+ manifest: {
3
+ v: 1;
4
+ exportedAt: string;
5
+ aliases: string[];
6
+ };
7
+ /** Raw config.json contents (plaintext registry; no secrets). */
8
+ config: string | null;
9
+ /** alias -> raw <alias>.enc contents (still under the SOURCE MASTER_KEY). */
10
+ tokens: Record<string, string>;
11
+ }
12
+ export interface TransferDeps {
13
+ configPath?: string;
14
+ tokenDir?: string;
15
+ fileExists?: (p: string) => boolean;
16
+ readFile?: (p: string) => string;
17
+ listDir?: (p: string) => string[];
18
+ }
19
+ /** Collect the config + every `<alias>.enc` token file present in the token dir. */
20
+ export declare function buildTransferBundle(exportedAt: string, deps?: TransferDeps): TransferBundle;
21
+ export declare function encryptBundle(bundle: TransferBundle, passphrase: string): string;
22
+ export declare class BundleDecryptError extends Error {
23
+ }
24
+ export declare function decryptBundle(contents: string, passphrase: string): TransferBundle;
25
+ export interface ImportPlan {
26
+ mergedConfig: string;
27
+ tokenWrites: Record<string, string>;
28
+ added: string[];
29
+ replaced: string[];
30
+ skipped: string[];
31
+ }
32
+ /**
33
+ * Merge the bundle into the local registry. Default MERGE = add non-colliding
34
+ * aliases; a collision is skipped (never clobbers a local account's tokens)
35
+ * unless `replace` is set. Pure for unit testing.
36
+ */
37
+ export declare function planImport(bundle: TransferBundle, localConfigRaw: string | null, opts?: {
38
+ replace?: boolean;
39
+ }): ImportPlan;
40
+ export declare function runExportCli(argv: string[]): Promise<number>;
41
+ export declare function runImportCli(argv: string[]): Promise<number>;
@@ -0,0 +1,219 @@
1
+ // B19: encrypted registry export / import (account-registry.md). Moves a whole
2
+ // setup to another machine. The bundle is encrypted under a user PASSPHRASE
3
+ // (portable across machines with different MASTER_KEYs) via the token-store AES
4
+ // primitives; the bundled <alias>.enc token files stay encrypted under the
5
+ // SOURCE MASTER_KEY (double-locked), so the target needs the same MASTER_KEY to
6
+ // use them. Secrets (MASTER_KEY / client secret) are never in the bundle.
7
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
8
+ import * as readline from 'node:readline';
9
+ import path from 'node:path';
10
+ import { encryptToken, decryptToken } from './token-store.js';
11
+ import { atomicWriteFileSync, atomicWriteWithLock } from './fs-atomic.js';
12
+ import { configFilePath, CONFIG_VERSION } from './config-file.js';
13
+ import { getTokenDir } from './accounts.js';
14
+ const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
15
+ function resolveDeps(deps) {
16
+ return {
17
+ configPath: deps.configPath ?? configFilePath(),
18
+ tokenDir: deps.tokenDir ?? getTokenDir(),
19
+ fileExists: deps.fileExists ?? existsSync,
20
+ readFile: deps.readFile ?? ((p) => readFileSync(p, 'utf-8')),
21
+ listDir: deps.listDir ?? ((p) => (existsSync(p) ? readdirSync(p) : [])),
22
+ };
23
+ }
24
+ /** Collect the config + every `<alias>.enc` token file present in the token dir. */
25
+ export function buildTransferBundle(exportedAt, deps = {}) {
26
+ const r = resolveDeps(deps);
27
+ const config = r.fileExists(r.configPath) ? r.readFile(r.configPath) : null;
28
+ const tokens = {};
29
+ for (const name of r.listDir(r.tokenDir)) {
30
+ if (!name.endsWith('.enc'))
31
+ continue;
32
+ const alias = name.slice(0, -'.enc'.length);
33
+ if (!ALIAS_RE.test(alias))
34
+ continue; // never trust a stray filename as an alias
35
+ tokens[alias] = r.readFile(path.join(r.tokenDir, name));
36
+ }
37
+ // Alias list = token-authed aliases plus any account defined only in config.
38
+ const aliasSet = new Set(Object.keys(tokens));
39
+ if (config) {
40
+ try {
41
+ const accounts = JSON.parse(config).accounts ?? {};
42
+ for (const a of Object.keys(accounts))
43
+ aliasSet.add(a);
44
+ }
45
+ catch {
46
+ // malformed config is passed through as-is; import will re-validate it.
47
+ }
48
+ }
49
+ return { manifest: { v: 1, exportedAt, aliases: [...aliasSet].sort() }, config, tokens };
50
+ }
51
+ export function encryptBundle(bundle, passphrase) {
52
+ return encryptToken(bundle, passphrase);
53
+ }
54
+ export class BundleDecryptError extends Error {
55
+ }
56
+ export function decryptBundle(contents, passphrase) {
57
+ let obj;
58
+ try {
59
+ obj = decryptToken(contents, passphrase);
60
+ }
61
+ catch {
62
+ throw new BundleDecryptError('Could not decrypt the bundle — wrong passphrase or the file is corrupt.');
63
+ }
64
+ const b = obj;
65
+ if (!b || typeof b !== 'object' || !b.manifest || b.manifest.v !== 1 || typeof b.tokens !== 'object') {
66
+ throw new BundleDecryptError('Bundle decrypted but is not a valid registry export (bad manifest).');
67
+ }
68
+ return b;
69
+ }
70
+ function parseConfig(raw) {
71
+ if (!raw || raw.trim() === '')
72
+ return { version: CONFIG_VERSION, accounts: {} };
73
+ const parsed = JSON.parse(raw);
74
+ if (typeof parsed !== 'object' || parsed === null)
75
+ throw new Error('config is not an object');
76
+ parsed.accounts ??= {};
77
+ parsed.version ??= CONFIG_VERSION;
78
+ return parsed;
79
+ }
80
+ /**
81
+ * Merge the bundle into the local registry. Default MERGE = add non-colliding
82
+ * aliases; a collision is skipped (never clobbers a local account's tokens)
83
+ * unless `replace` is set. Pure for unit testing.
84
+ */
85
+ export function planImport(bundle, localConfigRaw, opts = {}) {
86
+ const local = parseConfig(localConfigRaw);
87
+ const incoming = parseConfig(bundle.config);
88
+ const replace = opts.replace === true;
89
+ const added = [];
90
+ const replaced = [];
91
+ const skipped = [];
92
+ const nextAccounts = { ...local.accounts };
93
+ const nextProfiles = { ...(local.scopeProfiles ?? {}) };
94
+ const tokenWrites = {};
95
+ const incomingAccounts = incoming.accounts ?? {};
96
+ const incomingProfiles = incoming.scopeProfiles ?? {};
97
+ for (const alias of Object.keys(incomingAccounts)) {
98
+ const collides = Object.prototype.hasOwnProperty.call(local.accounts, alias);
99
+ if (collides && !replace) {
100
+ skipped.push(alias);
101
+ continue;
102
+ }
103
+ nextAccounts[alias] = incomingAccounts[alias];
104
+ // carry the account's scope profile if it points at one
105
+ const row = incomingAccounts[alias];
106
+ if (row?.scopeProfile && Object.prototype.hasOwnProperty.call(incomingProfiles, row.scopeProfile)) {
107
+ nextProfiles[row.scopeProfile] = incomingProfiles[row.scopeProfile];
108
+ }
109
+ if (bundle.tokens[alias])
110
+ tokenWrites[alias] = bundle.tokens[alias];
111
+ (collides ? replaced : added).push(alias);
112
+ }
113
+ // Token files with no config account (authed but registry-less) follow the
114
+ // same collision rule keyed on whether we already wrote/kept that alias.
115
+ for (const alias of Object.keys(bundle.tokens)) {
116
+ if (alias in tokenWrites || added.includes(alias) || replaced.includes(alias))
117
+ continue;
118
+ if (Object.prototype.hasOwnProperty.call(local.accounts, alias) && !replace)
119
+ continue;
120
+ tokenWrites[alias] = bundle.tokens[alias];
121
+ }
122
+ const merged = { ...local, version: local.version ?? CONFIG_VERSION, accounts: nextAccounts };
123
+ if (Object.keys(nextProfiles).length > 0)
124
+ merged.scopeProfiles = nextProfiles;
125
+ return {
126
+ mergedConfig: `${JSON.stringify(merged, null, 2)}\n`,
127
+ tokenWrites,
128
+ added: added.sort(),
129
+ replaced: replaced.sort(),
130
+ skipped: skipped.sort(),
131
+ };
132
+ }
133
+ // ---- CLI -------------------------------------------------------------------
134
+ function argFlag(argv, name) {
135
+ const i = argv.indexOf(name);
136
+ return i >= 0 ? argv[i + 1] : undefined;
137
+ }
138
+ /** Passphrase from MCP_TRANSFER_PASSPHRASE (automation) or a TTY prompt; never
139
+ * a CLI flag (would leak in the process list). */
140
+ async function resolvePassphrase(prompt) {
141
+ const fromEnv = process.env.MCP_TRANSFER_PASSPHRASE;
142
+ if (fromEnv && fromEnv.length > 0)
143
+ return fromEnv;
144
+ if (!process.stdin.isTTY)
145
+ return null;
146
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
147
+ try {
148
+ const answer = await new Promise((resolve) => rl.question(prompt, resolve));
149
+ return answer.trim() || null;
150
+ }
151
+ finally {
152
+ rl.close();
153
+ }
154
+ }
155
+ export async function runExportCli(argv) {
156
+ const out = argFlag(argv, '--out');
157
+ if (!out) {
158
+ console.error('Usage: mcp-google-multi account export --out <bundle.enc>');
159
+ return 2;
160
+ }
161
+ const passphrase = await resolvePassphrase('Choose a passphrase to encrypt the export: ');
162
+ if (!passphrase) {
163
+ console.error('E_INPUT_REQUIRED: a passphrase is required. Set MCP_TRANSFER_PASSPHRASE or run in a terminal.');
164
+ return 2;
165
+ }
166
+ const bundle = buildTransferBundle(new Date().toISOString());
167
+ atomicWriteFileSync(path.resolve(out), encryptBundle(bundle, passphrase), 0o600);
168
+ console.error(`Exported ${bundle.manifest.aliases.length} account(s) to ${out} (0600).`);
169
+ console.error('Note: the bundled token files stay encrypted under this machine\'s MASTER_KEY. To use the tokens on the target, set the same MASTER_KEY there; otherwise re-authenticate with account_reauth.');
170
+ return 0;
171
+ }
172
+ export async function runImportCli(argv) {
173
+ const file = argv[argv.indexOf('import') + 1];
174
+ if (!file || file.startsWith('--')) {
175
+ console.error('Usage: mcp-google-multi account import <bundle.enc> [--replace]');
176
+ return 2;
177
+ }
178
+ const replace = argv.includes('--replace');
179
+ if (!existsSync(path.resolve(file))) {
180
+ console.error(`E_NOT_FOUND: ${file}`);
181
+ return 2;
182
+ }
183
+ const passphrase = await resolvePassphrase('Passphrase to decrypt the bundle: ');
184
+ if (!passphrase) {
185
+ console.error('E_INPUT_REQUIRED: a passphrase is required. Set MCP_TRANSFER_PASSPHRASE or run in a terminal.');
186
+ return 2;
187
+ }
188
+ let bundle;
189
+ try {
190
+ bundle = decryptBundle(readFileSync(path.resolve(file), 'utf-8'), passphrase);
191
+ }
192
+ catch (e) {
193
+ console.error(e.message);
194
+ return 1;
195
+ }
196
+ const cfgPath = configFilePath();
197
+ const localRaw = existsSync(cfgPath) ? readFileSync(cfgPath, 'utf-8') : null;
198
+ let plan;
199
+ try {
200
+ plan = planImport(bundle, localRaw, { replace });
201
+ }
202
+ catch (e) {
203
+ console.error(`E_CONFIG_INVALID: the bundle's config could not be merged: ${e.message}`);
204
+ return 1;
205
+ }
206
+ atomicWriteWithLock(cfgPath, plan.mergedConfig, 0o600);
207
+ const tokenDir = getTokenDir();
208
+ for (const [alias, contents] of Object.entries(plan.tokenWrites)) {
209
+ atomicWriteFileSync(path.join(tokenDir, `${alias}.enc`), contents, 0o600);
210
+ }
211
+ if (process.env.GOOGLE_ACCOUNTS?.trim()) {
212
+ console.error('Note: GOOGLE_ACCOUNTS is set, so config.json is ignored (env-sourced mode). The imported registry takes effect once you unset GOOGLE_ACCOUNTS. Token files were still placed.');
213
+ }
214
+ console.error(`Imported: ${plan.added.length} added${plan.added.length ? ` (${plan.added.join(', ')})` : ''}, ` +
215
+ `${plan.replaced.length} replaced${plan.replaced.length ? ` (${plan.replaced.join(', ')})` : ''}, ` +
216
+ `${plan.skipped.length} skipped${plan.skipped.length ? ` (${plan.skipped.join(', ')}; re-run with --replace to overwrite)` : ''}.`);
217
+ console.error('Run `mcp-google-multi doctor` to verify token health (tokens need the source MASTER_KEY; otherwise account_reauth).');
218
+ return 0;
219
+ }
@@ -2,6 +2,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
3
  import { type Policy } from './write-control.js';
4
4
  export type Cud = 'read' | 'create' | 'update' | 'delete';
5
+ export type DiscoveryMode = 'lazy' | 'curated' | 'eager';
6
+ export declare function resolveDiscoveryMode(env?: NodeJS.ProcessEnv): DiscoveryMode;
5
7
  export interface ToolEntry {
6
8
  name: string;
7
9
  service: string;
@@ -9,7 +11,18 @@ export interface ToolEntry {
9
11
  description: string;
10
12
  inputShape: z.ZodRawShape;
11
13
  annotations: Record<string, unknown>;
14
+ /** anthropic/* client extension keys: honoring clients (Claude Code) read
15
+ * these from the wire Tool's _meta — SDK clients STRIP unknown keys from
16
+ * annotations (closed ToolAnnotationsSchema), so they must never live there. */
17
+ clientMeta?: Record<string, unknown>;
12
18
  meta: boolean;
19
+ /** Discovery-codegen provenance (the only tools passing an explicit cud). */
20
+ generated: boolean;
21
+ /** Member of the frozen irreversible set (real send / permanent delete). */
22
+ irreversible: boolean;
23
+ /** Baked per-method scopes (generated tools); curated tools authorize at
24
+ * service/bundle grain and leave this undefined. */
25
+ requiredScopes?: readonly string[];
13
26
  }
14
27
  export interface CatalogOperation {
15
28
  tool: string;
@@ -27,11 +40,24 @@ export declare class ToolRegistry {
27
40
  private readonly jsonSchemaCache;
28
41
  private readonly compactOutput;
29
42
  private registeringMeta;
30
- constructor(server: McpServer, policy: Policy);
43
+ /** Configured visibility mode (GOOGLE_DISCOVERY); default lazy = v5 exact. */
44
+ readonly mode: DiscoveryMode;
45
+ /** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
46
+ * lazy surface to curated without touching the configured mode. */
47
+ private expanded;
48
+ constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode);
31
49
  registerMeta: McpServer['registerTool'];
32
50
  services(): string[];
33
51
  catalog(service: string, query?: string): CatalogOperation[];
34
52
  reveal(service: string): boolean;
53
+ /** discover_all: advertise the full curated set at once. Idempotent. */
54
+ expand(): boolean;
55
+ /** discover_reset: back to the lean meta-only surface. Clears reveals too.
56
+ * BV-8: if a client mishandles a SHRINKING tools/list, this degrades to a
57
+ * no-op for that session — tools stay callable regardless (graceful
58
+ * dispatch), zero correctness impact. */
59
+ collapse(): boolean;
60
+ private effectiveMode;
35
61
  isVisible(tool: ToolEntry): boolean;
36
62
  visibleCount(): {
37
63
  eager: number;
package/dist/registry.js CHANGED
@@ -1,11 +1,32 @@
1
1
  import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { z } from 'zod';
3
- import { isAllowed, writeDisabledResult } from './write-control.js';
3
+ import { isAllowed, writeDisabledResult, IRREVERSIBLE_TOOLS } from './write-control.js';
4
+ import { getAccountSet, refreshAccountSetIfStale } from './accounts.js';
4
5
  import { compactResult, trimEnabled } from './trim.js';
5
6
  import { fanoutAccountField, invalidAccountsResult, parseAccountSelector, runFanout } from './fanout.js';
7
+ const DISCOVERY_MODES = ['lazy', 'curated', 'eager'];
8
+ export function resolveDiscoveryMode(env = process.env) {
9
+ const raw = (env.GOOGLE_DISCOVERY ?? 'lazy').trim();
10
+ if (raw && !DISCOVERY_MODES.includes(raw)) {
11
+ // Fail-open to the lean default, but say so — a typo'd mode otherwise
12
+ // looks like tools silently missing (or silently flooding the context).
13
+ process.stderr.write(`GOOGLE_DISCOVERY="${raw}" is not valid (${DISCOVERY_MODES.join(' | ')}); using lazy\n`);
14
+ }
15
+ return DISCOVERY_MODES.includes(raw) ? raw : 'lazy';
16
+ }
6
17
  const CUD_OVERRIDES = {
7
18
  drive_untrash: 'update',
8
19
  drive_transfer: 'create',
20
+ // read-only resolver; the name's "resolve" verb would otherwise infer update.
21
+ contacts_resolve: 'read',
22
+ // Account management is gated by anthropic/requiresUserInteraction (forced
23
+ // human approval), NOT the Google-data write-control profile — so onboarding
24
+ // works under any profile. cud=read also keeps them off the fan-out path.
25
+ account_add: 'read',
26
+ account_reauth: 'read',
27
+ // Writes a LOCAL MCP-client config, not Google data; gated by
28
+ // requiresUserInteraction. "write" verb would otherwise infer update.
29
+ account_write_config: 'read',
9
30
  };
10
31
  const SERVICE_OVERRIDES = {
11
32
  reports_activities_list: 'admin',
@@ -13,7 +34,13 @@ const SERVICE_OVERRIDES = {
13
34
  // read tools that write local files — same savePath fanned across accounts would clobber
14
35
  const FANOUT_EXCLUDE = new Set(['gmail_download_attachment', 'drive_download', 'drive_export']);
15
36
  function isAccountEnum(field) {
16
- return field?._zod?.def?.type === 'enum';
37
+ const def = field?._zod?.def;
38
+ if (!def)
39
+ return false;
40
+ // A2 made account enums .optional(); unwrap it or fan-out silently dies.
41
+ if (def.type === 'optional')
42
+ return def.innerType?._zod?.def?.type === 'enum';
43
+ return def.type === 'enum';
17
44
  }
18
45
  const DELETE_VERB = /(^|_)(delete|remove|trash|clear|empty)(_|$)/;
19
46
  const CREATE_VERB = /(^|_)(create|add|insert|send|upload|copy|import|append|submit|duplicate|share|quick)(_|$)/;
@@ -39,21 +66,37 @@ export class ToolRegistry {
39
66
  jsonSchemaCache = new Map();
40
67
  compactOutput = trimEnabled();
41
68
  registeringMeta = false;
42
- constructor(server, policy) {
69
+ /** Configured visibility mode (GOOGLE_DISCOVERY); default lazy = v5 exact. */
70
+ mode;
71
+ /** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
72
+ * lazy surface to curated without touching the configured mode. */
73
+ expanded = false;
74
+ constructor(server, policy, mode = resolveDiscoveryMode()) {
43
75
  this.server = server;
44
76
  this.policy = policy;
77
+ this.mode = mode;
45
78
  this.registerTool = ((name, config, handler) => {
46
79
  const service = SERVICE_OVERRIDES[name] ?? (name.includes('_') ? name.slice(0, name.indexOf('_')) : name);
47
80
  const cud = config.cud ?? inferCud(name);
48
81
  // destructiveHint=false claims "additive only" (MCP spec) — updates overwrite, so they stay true.
82
+ // idempotent: reads trivially, deletes (already-gone = same), updates
83
+ // (overwrite converges); creates are not (send twice = two emails).
49
84
  const annotations = {
50
85
  readOnlyHint: cud === 'read',
51
86
  destructiveHint: cud === 'delete' || cud === 'update',
87
+ idempotentHint: cud !== 'create',
52
88
  ...config.annotations,
53
89
  };
90
+ // A12: forced per-call human approval on the irreversible set, even in
91
+ // bypass mode. Client-enforced via the wire _meta (Claude Code reads
92
+ // anthropic/* ONLY there); the server verdict stays separate.
93
+ const clientMeta = IRREVERSIBLE_TOOLS.has(name)
94
+ ? { ...config._meta, 'anthropic/requiresUserInteraction': true }
95
+ : config._meta;
54
96
  // never fan out meta tools: google_api_call infers cud=read but executes writes
55
97
  let inputShape = config.inputSchema ?? {};
56
98
  let baseHandler = handler;
99
+ const hasAccountField = 'account' in inputShape;
57
100
  if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) && isAccountEnum(inputShape.account)) {
58
101
  const description = inputShape.account.description ?? 'Google account alias';
59
102
  inputShape = { ...inputShape, account: fanoutAccountField(description) };
@@ -74,16 +117,54 @@ export class ToolRegistry {
74
117
  description: config.description ?? '',
75
118
  inputShape,
76
119
  annotations,
120
+ clientMeta,
77
121
  meta: this.registeringMeta,
122
+ generated: config.cud !== undefined,
123
+ irreversible: IRREVERSIBLE_TOOLS.has(name),
124
+ requiredScopes: config.requiredScopes,
78
125
  });
79
126
  const guarded = cud === 'read'
80
127
  ? baseHandler
81
128
  : (...args) => isAllowed({ name, service, cud }, policy)
82
129
  ? baseHandler(...args)
83
130
  : writeDisabledResult({ name, service, cud }, policy);
131
+ // A2: the ONE default-account injection site — outside the CUD gate and
132
+ // the fan-out parse so both observe a concrete alias; NOT meta-skipped
133
+ // (that is what covers google_api_call with zero bespoke code). Explicit
134
+ // aliases, "*" and CSV pass through untouched.
135
+ const withDefault = !hasAccountField
136
+ ? guarded
137
+ : (...args) => {
138
+ const first = args[0];
139
+ const value = first?.account;
140
+ if (value != null && value !== '')
141
+ return guarded(...args);
142
+ // Refresh here or the unset->configured default transition never
143
+ // heals for a client that always omits account (this branch never
144
+ // reaches getClient's probe). One stat, only on omission.
145
+ refreshAccountSetIfStale();
146
+ const def = getAccountSet().defaultAccount;
147
+ if (!def) {
148
+ return {
149
+ content: [
150
+ {
151
+ type: 'text',
152
+ text: JSON.stringify({
153
+ error: 'E_NO_DEFAULT_ACCOUNT',
154
+ message: 'No "account" given and no default account is configured.',
155
+ hint: `Pass account explicitly (valid: ${getAccountSet().aliases.join(', ')}), or set GOOGLE_DEFAULT_ACCOUNT / "defaultAccount" in config.json.`,
156
+ retriable: false,
157
+ }),
158
+ },
159
+ ],
160
+ isError: true,
161
+ };
162
+ }
163
+ return guarded({ ...(first ?? {}), account: def }, ...args.slice(1));
164
+ };
84
165
  const finalHandler = this.compactOutput
85
- ? async (...args) => compactResult(await guarded(...args))
86
- : guarded;
166
+ ? async (...args) => compactResult(await withDefault(...args))
167
+ : withDefault;
87
168
  const { cud: _cud, ...sdkConfig } = config;
88
169
  return server.registerTool(name, { ...sdkConfig, inputSchema: inputShape, annotations }, finalHandler);
89
170
  });
@@ -119,8 +200,37 @@ export class ToolRegistry {
119
200
  this.server.sendToolListChanged();
120
201
  return true;
121
202
  }
203
+ /** discover_all: advertise the full curated set at once. Idempotent. */
204
+ expand() {
205
+ if (this.expanded || this.effectiveMode() !== 'lazy')
206
+ return false;
207
+ this.expanded = true;
208
+ this.server.sendToolListChanged();
209
+ return true;
210
+ }
211
+ /** discover_reset: back to the lean meta-only surface. Clears reveals too.
212
+ * BV-8: if a client mishandles a SHRINKING tools/list, this degrades to a
213
+ * no-op for that session — tools stay callable regardless (graceful
214
+ * dispatch), zero correctness impact. */
215
+ collapse() {
216
+ if (!this.expanded && this.revealed.size === 0)
217
+ return false;
218
+ this.expanded = false;
219
+ this.revealed.clear();
220
+ this.server.sendToolListChanged();
221
+ return true;
222
+ }
223
+ effectiveMode() {
224
+ if (this.mode !== 'lazy')
225
+ return this.mode;
226
+ return this.expanded ? 'curated' : 'lazy';
227
+ }
122
228
  isVisible(tool) {
123
- return tool.meta || this.revealed.has(tool.service);
229
+ const mode = this.effectiveMode();
230
+ return (tool.meta ||
231
+ mode === 'eager' ||
232
+ this.revealed.has(tool.service) ||
233
+ (mode === 'curated' && !tool.generated));
124
234
  }
125
235
  visibleCount() {
126
236
  const meta = this.tools.filter((t) => t.meta).length;
@@ -148,6 +258,7 @@ export class ToolRegistry {
148
258
  description: tool.description,
149
259
  inputSchema,
150
260
  annotations: tool.annotations,
261
+ ...(tool.clientMeta ? { _meta: tool.clientMeta } : {}),
151
262
  };
152
263
  }
153
264
  }
@@ -0,0 +1,19 @@
1
+ export interface ScopeProfile {
2
+ includesBase?: boolean;
3
+ bundles: string[];
4
+ admin?: boolean;
5
+ }
6
+ export interface BundleCatalogEntry {
7
+ scopes: string[];
8
+ description: string;
9
+ risk: 'low' | 'medium' | 'high';
10
+ workspaceOnly?: boolean;
11
+ }
12
+ export declare const ADMIN_SCOPES: string[];
13
+ export declare const BUNDLE_CATALOG: Record<string, BundleCatalogEntry>;
14
+ /** One-way soft-rename map (UD5): empty at 6.0.0; the mechanism ships now so a
15
+ * future rename is a deprecation warning, never a breaking config change. */
16
+ export declare const BUNDLE_ALIASES: Record<string, string>;
17
+ export declare function resolveBundleAliases(bundles: string[]): string[];
18
+ /** Closest catalog key for E_UNKNOWN_BUNDLE remediation (edit distance <= 2). */
19
+ export declare function closestBundle(name: string): string | undefined;