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.
- package/README.md +21 -16
- package/dist/accounts.d.ts +44 -5
- package/dist/accounts.js +267 -25
- package/dist/auth.d.ts +11 -4
- package/dist/auth.js +85 -78
- package/dist/client-config.d.ts +90 -0
- package/dist/client-config.js +282 -0
- package/dist/client.js +10 -4
- package/dist/config-file.d.ts +38 -0
- package/dist/config-file.js +117 -0
- package/dist/discover.js +57 -3
- package/dist/doctor.d.ts +86 -0
- package/dist/doctor.js +384 -0
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +52 -0
- package/dist/executor.d.ts +2 -0
- package/dist/executor.js +2 -1
- package/dist/fanout.js +8 -5
- package/dist/fs-atomic.d.ts +3 -0
- package/dist/fs-atomic.js +123 -0
- package/dist/http-config.d.ts +35 -0
- package/dist/http-config.js +113 -0
- package/dist/http-transport.d.ts +59 -0
- package/dist/http-transport.js +269 -0
- package/dist/identity.d.ts +24 -0
- package/dist/identity.js +19 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +199 -18
- package/dist/master-key.d.ts +53 -0
- package/dist/master-key.js +242 -0
- package/dist/mcp-token.d.ts +81 -0
- package/dist/mcp-token.js +176 -0
- package/dist/migrate-config.d.ts +7 -0
- package/dist/migrate-config.js +103 -0
- package/dist/migrate-tokens.js +4 -3
- package/dist/net-tuning.d.ts +8 -0
- package/dist/net-tuning.js +25 -0
- package/dist/oauth-as.d.ts +52 -0
- package/dist/oauth-as.js +457 -0
- package/dist/oauth-consent.d.ts +28 -0
- package/dist/oauth-consent.js +109 -0
- package/dist/open-url.d.ts +1 -0
- package/dist/open-url.js +30 -0
- package/dist/reauth-hint.d.ts +2 -0
- package/dist/reauth-hint.js +15 -0
- package/dist/registry-transfer.d.ts +41 -0
- package/dist/registry-transfer.js +219 -0
- package/dist/registry.d.ts +27 -1
- package/dist/registry.js +117 -6
- package/dist/scope-catalog.d.ts +19 -0
- package/dist/scope-catalog.js +182 -0
- package/dist/scope-observability.d.ts +78 -0
- package/dist/scope-observability.js +192 -0
- package/dist/services.js +1 -1
- package/dist/setup-prompt.d.ts +9 -0
- package/dist/setup-prompt.js +59 -0
- package/dist/ssrf-guard.d.ts +48 -0
- package/dist/ssrf-guard.js +242 -0
- package/dist/token-store.js +23 -125
- package/dist/tools/_errors.d.ts +8 -2
- package/dist/tools/_errors.js +97 -6
- package/dist/tools/account-wizard.d.ts +33 -0
- package/dist/tools/account-wizard.js +284 -0
- package/dist/tools/accounts-tool.d.ts +8 -5
- package/dist/tools/accounts-tool.js +38 -15
- package/dist/tools/admin.js +2 -2
- package/dist/tools/calendar.js +2 -2
- package/dist/tools/chat.js +2 -2
- package/dist/tools/contacts.d.ts +40 -0
- package/dist/tools/contacts.js +135 -2
- package/dist/tools/docs.d.ts +3 -0
- package/dist/tools/docs.js +60 -9
- package/dist/tools/drive.d.ts +3 -0
- package/dist/tools/drive.js +60 -8
- package/dist/tools/forms.js +4 -2
- package/dist/tools/generated/_shared.d.ts +1 -3
- package/dist/tools/generated/_shared.js +3 -3
- package/dist/tools/generated/admin.js +175 -132
- package/dist/tools/generated/appsmarket.js +6 -2
- package/dist/tools/generated/calendar.js +45 -27
- package/dist/tools/generated/chat.js +81 -47
- package/dist/tools/generated/classroom.js +131 -104
- package/dist/tools/generated/cloudidentity.js +73 -61
- package/dist/tools/generated/cloudsearch.js +59 -49
- package/dist/tools/generated/contacts.js +22 -14
- package/dist/tools/generated/drive.js +51 -35
- package/dist/tools/generated/driveactivity.js +5 -1
- package/dist/tools/generated/drivelabels.js +30 -25
- package/dist/tools/generated/forms.js +7 -3
- package/dist/tools/generated/gmail.js +73 -58
- package/dist/tools/generated/groupsmigration.js +5 -1
- package/dist/tools/generated/groupssettings.js +7 -3
- package/dist/tools/generated/keep.js +12 -7
- package/dist/tools/generated/licensing.js +11 -7
- package/dist/tools/generated/meet.js +20 -13
- package/dist/tools/generated/postmaster.js +8 -4
- package/dist/tools/generated/reseller.js +22 -17
- package/dist/tools/generated/script.js +26 -16
- package/dist/tools/generated/sheets.js +11 -7
- package/dist/tools/generated/tasks.js +6 -2
- package/dist/tools/generated/vault.js +38 -33
- package/dist/tools/generated/workspaceevents.js +12 -7
- package/dist/tools/gmail-mime.d.ts +38 -8
- package/dist/tools/gmail-mime.js +75 -71
- package/dist/tools/gmail.d.ts +31 -0
- package/dist/tools/gmail.js +483 -99
- package/dist/tools/google-api.js +7 -4
- package/dist/tools/meet.js +2 -2
- package/dist/tools/searchconsole.js +2 -2
- package/dist/tools/sheets.js +6 -2
- package/dist/tools/slides.js +4 -2
- package/dist/tools/tasks.js +2 -2
- package/dist/types.d.ts +1 -1
- package/dist/write-control.d.ts +24 -1
- package/dist/write-control.js +26 -1
- package/package.json +26 -7
package/dist/open-url.js
ADDED
|
@@ -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,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
|
+
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
86
|
-
:
|
|
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
|
-
|
|
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;
|