mcp-google-multi 6.0.0-beta.2 → 6.0.0-beta.3
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/dist/accounts.d.ts +17 -1
- package/dist/accounts.js +52 -15
- package/dist/api-probe.d.ts +5 -0
- package/dist/api-probe.js +20 -15
- package/dist/arg-normalize.js +6 -5
- package/dist/arg-strict.d.ts +7 -0
- package/dist/arg-strict.js +34 -4
- package/dist/auth.d.ts +4 -3
- package/dist/auth.js +11 -13
- package/dist/boot-gates.d.ts +26 -0
- package/dist/boot-gates.js +38 -0
- package/dist/cimd-cache.d.ts +38 -0
- package/dist/cimd-cache.js +117 -0
- package/dist/client.d.ts +32 -2
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +11 -0
- package/dist/compose.js +109 -0
- package/dist/config-file.d.ts +13 -0
- package/dist/config-file.js +37 -1
- package/dist/discover.js +3 -1
- package/dist/discovery-client.d.ts +3 -0
- package/dist/discovery-client.js +25 -2
- package/dist/doctor.d.ts +6 -2
- package/dist/doctor.js +119 -23
- package/dist/executor.d.ts +3 -0
- package/dist/executor.js +4 -2
- package/dist/fanout.d.ts +9 -1
- package/dist/fanout.js +44 -10
- package/dist/fs-atomic.d.ts +11 -0
- package/dist/fs-atomic.js +219 -28
- package/dist/hint-provider.d.ts +129 -0
- package/dist/hint-provider.js +64 -0
- package/dist/http-transport.d.ts +62 -4
- package/dist/http-transport.js +343 -24
- package/dist/identity.d.ts +4 -1
- package/dist/identity.js +25 -6
- package/dist/index.js +66 -138
- package/dist/master-key.js +13 -5
- package/dist/mcp-token.d.ts +136 -19
- package/dist/mcp-token.js +569 -72
- package/dist/migrate-config.js +2 -2
- package/dist/oauth-as.d.ts +103 -3
- package/dist/oauth-as.js +374 -79
- package/dist/oauth-consent.d.ts +10 -0
- package/dist/oauth-consent.js +16 -0
- package/dist/outbound-allowlist.js +5 -2
- package/dist/reauth-hint.d.ts +1 -1
- package/dist/reauth-hint.js +12 -8
- package/dist/registry-transfer.d.ts +5 -0
- package/dist/registry-transfer.js +65 -5
- package/dist/registry.d.ts +18 -1
- package/dist/registry.js +71 -15
- package/dist/scope-catalog.d.ts +11 -0
- package/dist/scope-catalog.js +23 -6
- package/dist/scope-observability.d.ts +8 -6
- package/dist/scope-observability.js +29 -13
- package/dist/services.d.ts +15 -10
- package/dist/services.js +59 -15
- package/dist/ssrf-guard.d.ts +23 -9
- package/dist/ssrf-guard.js +101 -62
- package/dist/tenant-purge.d.ts +26 -0
- package/dist/tenant-purge.js +67 -0
- package/dist/token-store.d.ts +20 -2
- package/dist/token-store.js +0 -0
- package/dist/tools/_errors.d.ts +3 -0
- package/dist/tools/_errors.js +53 -24
- package/dist/tools/_local-files.d.ts +9 -0
- package/dist/tools/_local-files.js +18 -0
- package/dist/tools/account-wizard.d.ts +10 -1
- package/dist/tools/account-wizard.js +74 -17
- package/dist/tools/accounts-tool.d.ts +7 -0
- package/dist/tools/accounts-tool.js +34 -18
- package/dist/tools/admin.d.ts +10 -1
- package/dist/tools/admin.js +15 -11
- package/dist/tools/analytics.d.ts +10 -1
- package/dist/tools/analytics.js +13 -8
- package/dist/tools/calendar.d.ts +2 -1
- package/dist/tools/calendar.js +18 -14
- package/dist/tools/chat.d.ts +10 -1
- package/dist/tools/chat.js +13 -8
- package/dist/tools/contacts.d.ts +2 -1
- package/dist/tools/contacts.js +17 -13
- package/dist/tools/docs.d.ts +2 -1
- package/dist/tools/docs.js +34 -30
- package/dist/tools/drive.d.ts +2 -1
- package/dist/tools/drive.js +65 -50
- package/dist/tools/forms.d.ts +10 -1
- package/dist/tools/forms.js +19 -12
- package/dist/tools/generated/_shared.d.ts +2 -1
- package/dist/tools/generated/_shared.js +7 -3
- package/dist/tools/generated/admin.d.ts +2 -1
- package/dist/tools/generated/admin.js +265 -265
- package/dist/tools/generated/analytics.d.ts +2 -1
- package/dist/tools/generated/analytics.js +125 -125
- package/dist/tools/generated/appsmarket.d.ts +2 -1
- package/dist/tools/generated/appsmarket.js +5 -5
- package/dist/tools/generated/calendar.d.ts +2 -1
- package/dist/tools/generated/calendar.js +55 -55
- package/dist/tools/generated/chat.d.ts +2 -1
- package/dist/tools/generated/chat.js +95 -95
- package/dist/tools/generated/classroom.d.ts +2 -1
- package/dist/tools/generated/classroom.js +209 -209
- package/dist/tools/generated/cloudidentity.d.ts +2 -1
- package/dist/tools/generated/cloudidentity.js +133 -133
- package/dist/tools/generated/cloudsearch.d.ts +2 -1
- package/dist/tools/generated/cloudsearch.js +99 -99
- package/dist/tools/generated/contacts.d.ts +2 -1
- package/dist/tools/generated/contacts.js +29 -29
- package/dist/tools/generated/drive.d.ts +2 -1
- package/dist/tools/generated/drive.js +71 -71
- package/dist/tools/generated/driveactivity.d.ts +2 -1
- package/dist/tools/generated/driveactivity.js +3 -3
- package/dist/tools/generated/drivelabels.d.ts +2 -1
- package/dist/tools/generated/drivelabels.js +53 -53
- package/dist/tools/generated/forms.d.ts +2 -1
- package/dist/tools/generated/forms.js +7 -7
- package/dist/tools/generated/gmail.d.ts +2 -1
- package/dist/tools/generated/gmail.js +117 -117
- package/dist/tools/generated/groupsmigration.d.ts +2 -1
- package/dist/tools/generated/groupsmigration.js +3 -3
- package/dist/tools/generated/groupssettings.d.ts +2 -1
- package/dist/tools/generated/groupssettings.js +7 -7
- package/dist/tools/generated/index.d.ts +2 -1
- package/dist/tools/generated/keep.d.ts +2 -1
- package/dist/tools/generated/keep.js +15 -15
- package/dist/tools/generated/licensing.d.ts +2 -1
- package/dist/tools/generated/licensing.js +15 -15
- package/dist/tools/generated/meet.d.ts +2 -1
- package/dist/tools/generated/meet.js +27 -27
- package/dist/tools/generated/postmaster.d.ts +2 -1
- package/dist/tools/generated/postmaster.js +9 -9
- package/dist/tools/generated/reseller.d.ts +2 -1
- package/dist/tools/generated/reseller.js +35 -35
- package/dist/tools/generated/script.d.ts +2 -1
- package/dist/tools/generated/script.js +33 -33
- package/dist/tools/generated/searchconsole.d.ts +2 -1
- package/dist/tools/generated/searchconsole.js +3 -3
- package/dist/tools/generated/sheets.d.ts +2 -1
- package/dist/tools/generated/sheets.js +15 -15
- package/dist/tools/generated/tasks.d.ts +2 -1
- package/dist/tools/generated/tasks.js +5 -5
- package/dist/tools/generated/vault.d.ts +2 -1
- package/dist/tools/generated/vault.js +67 -67
- package/dist/tools/generated/workspaceevents.d.ts +2 -1
- package/dist/tools/generated/workspaceevents.js +31 -31
- package/dist/tools/gmail.d.ts +2 -1
- package/dist/tools/gmail.js +45 -33
- package/dist/tools/google-api.d.ts +2 -3
- package/dist/tools/google-api.js +30 -16
- package/dist/tools/meet.d.ts +10 -1
- package/dist/tools/meet.js +13 -9
- package/dist/tools/searchconsole.d.ts +2 -1
- package/dist/tools/searchconsole.js +17 -13
- package/dist/tools/sheets.d.ts +2 -1
- package/dist/tools/sheets.js +36 -32
- package/dist/tools/slides.d.ts +10 -1
- package/dist/tools/slides.js +17 -10
- package/dist/tools/tasks.d.ts +2 -1
- package/dist/tools/tasks.js +19 -15
- package/dist/trim.d.ts +5 -0
- package/dist/trim.js +20 -0
- package/dist/usage-metrics.d.ts +23 -0
- package/dist/usage-metrics.js +65 -47
- package/dist/write-control.js +10 -2
- package/package.json +27 -1
package/dist/oauth-consent.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { OAuth2Client } from 'googleapis-common';
|
|
2
|
+
/** Google authorize-URL options for the owner's HTTP authorization server.
|
|
3
|
+
* alias_reauth asks for the alias's scopes plus openid/email, which /callback
|
|
4
|
+
* needs to bind the returned identity to the alias. It carries no login_hint:
|
|
5
|
+
* the link works without authentication, so a hint would hand the alias's
|
|
6
|
+
* address to anyone who opens it. Every other flow is identity-only. */
|
|
7
|
+
export declare function ownerAuthUrlOptions(opts: {
|
|
8
|
+
flow: string;
|
|
9
|
+
alias?: string;
|
|
10
|
+
state: string;
|
|
11
|
+
}, scopesFor: (alias: string) => string[]): Parameters<OAuth2Client['generateAuthUrl']>[0];
|
|
2
12
|
/** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
|
|
3
13
|
export declare class ClientCredentialsMissingError extends Error {
|
|
4
14
|
constructor();
|
package/dist/oauth-consent.js
CHANGED
|
@@ -7,6 +7,22 @@ import { URL } from 'node:url';
|
|
|
7
7
|
// call forever. The listener binds an EPHEMERAL loopback port (RFC 8252 §7.3;
|
|
8
8
|
// Google Desktop clients accept any http://localhost:<port> redirect), so a
|
|
9
9
|
// second local process on a fixed port can never break auth.
|
|
10
|
+
/** Google authorize-URL options for the owner's HTTP authorization server.
|
|
11
|
+
* alias_reauth asks for the alias's scopes plus openid/email, which /callback
|
|
12
|
+
* needs to bind the returned identity to the alias. It carries no login_hint:
|
|
13
|
+
* the link works without authentication, so a hint would hand the alias's
|
|
14
|
+
* address to anyone who opens it. Every other flow is identity-only. */
|
|
15
|
+
export function ownerAuthUrlOptions(opts, scopesFor) {
|
|
16
|
+
if (opts.flow === 'alias_reauth' && opts.alias) {
|
|
17
|
+
return {
|
|
18
|
+
access_type: 'offline',
|
|
19
|
+
prompt: 'select_account consent',
|
|
20
|
+
scope: [...new Set([...scopesFor(opts.alias), 'openid', 'email'])],
|
|
21
|
+
state: opts.state,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return { scope: ['openid', 'email'], prompt: 'select_account', state: opts.state };
|
|
25
|
+
}
|
|
10
26
|
/** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
|
|
11
27
|
export class ClientCredentialsMissingError extends Error {
|
|
12
28
|
constructor() {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// or share outside the operator's list), a safety feature first and an
|
|
4
4
|
// enterprise checkbox second. FREE-core forever (ee-definition-v0 handoff).
|
|
5
5
|
// Off by default: an unset/empty GOOGLE_OUTBOUND_ALLOWLIST gates nothing.
|
|
6
|
+
import { hints } from './hint-provider.js';
|
|
6
7
|
/** Parse GOOGLE_OUTBOUND_ALLOWLIST: comma-separated addresses and @domain
|
|
7
8
|
* suffixes ("a@b.com, @company.com"). null = feature off (nothing gated). */
|
|
8
9
|
export function resolveOutboundAllowlist(env = process.env) {
|
|
@@ -34,6 +35,7 @@ export function outboundViolations(emails, env = process.env) {
|
|
|
34
35
|
/** The standard error envelope for a blocked outbound target. */
|
|
35
36
|
export function outboundDeniedEnvelope(kind, blocked, account, env = process.env) {
|
|
36
37
|
const list = resolveOutboundAllowlist(env);
|
|
38
|
+
const rendered = hints()?.render({ kind: 'outbound_denied', what: kind, blocked, entries: list?.entries ?? [] });
|
|
37
39
|
return {
|
|
38
40
|
content: [
|
|
39
41
|
{
|
|
@@ -41,8 +43,9 @@ export function outboundDeniedEnvelope(kind, blocked, account, env = process.env
|
|
|
41
43
|
text: JSON.stringify({
|
|
42
44
|
error: 'recipient_not_allowed',
|
|
43
45
|
message: `${kind} blocked by the outbound allowlist: ${blocked.join(', ')}.`,
|
|
44
|
-
hint:
|
|
45
|
-
|
|
46
|
+
hint: rendered?.hint ??
|
|
47
|
+
(`GOOGLE_OUTBOUND_ALLOWLIST is active (${(list?.entries ?? []).join(', ')}). ` +
|
|
48
|
+
'The operator must add the address (or its @domain) to the list, or unset the variable, to allow this target.'),
|
|
46
49
|
retriable: false,
|
|
47
50
|
account,
|
|
48
51
|
}),
|
package/dist/reauth-hint.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare function
|
|
1
|
+
export declare function setHttpReauthLink(linker: ((alias: string) => string) | null): void;
|
|
2
2
|
export declare function reauthHint(account: string): string;
|
package/dist/reauth-hint.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
// B13 in-call re-auth (BV-1): a dead/missing per-account Google token is a
|
|
2
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
|
|
4
|
-
// clientless alias_reauth flow (
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
// user cannot run a CLI, so the hint becomes a clickable, server-signed link
|
|
4
|
+
// into the AS's clientless alias_reauth flow (AuthServer.reauthLink). The HTTP
|
|
5
|
+
// bootstrap installs the linker when the AS starts; stdio never does.
|
|
6
|
+
import { hints } from './hint-provider.js';
|
|
7
|
+
let httpReauthLink = null;
|
|
8
|
+
export function setHttpReauthLink(linker) {
|
|
9
|
+
httpReauthLink = linker;
|
|
9
10
|
}
|
|
10
11
|
export function reauthHint(account) {
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
const rendered = hints()?.render({ kind: 'reauth', account });
|
|
13
|
+
if (rendered)
|
|
14
|
+
return rendered.hint;
|
|
15
|
+
if (httpReauthLink) {
|
|
16
|
+
return `Re-authenticate "${account}" in your browser, then retry: ${httpReauthLink(account)}`;
|
|
13
17
|
}
|
|
14
18
|
return `Run: npx mcp-google-multi auth --account ${account}`;
|
|
15
19
|
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** D11 (refuse-only for v1): export/import operate on the FLAT single-owner
|
|
2
|
+
* store. A tenant-namespaced layout means the bundle would either miss every
|
|
3
|
+
* tenant's tokens or exfiltrate all of them in one file — refuse instead.
|
|
4
|
+
* Returns the offending dir, or null when the store is flat. */
|
|
5
|
+
export declare function refuseIfMultiTenant(dir?: string): string | null;
|
|
1
6
|
export interface TransferBundle {
|
|
2
7
|
manifest: {
|
|
3
8
|
v: 1;
|
|
@@ -7,11 +7,24 @@
|
|
|
7
7
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
8
8
|
import * as readline from 'node:readline';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import {
|
|
10
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
|
|
11
11
|
import { atomicWriteFileSync, atomicWriteWithLock } from './fs-atomic.js';
|
|
12
|
-
import { configFilePath, CONFIG_VERSION } from './config-file.js';
|
|
12
|
+
import { ALIAS_RE, configFilePath, CONFIG_VERSION, tenantsDir } from './config-file.js';
|
|
13
13
|
import { getTokenDir } from './accounts.js';
|
|
14
|
-
|
|
14
|
+
/** D11 (refuse-only for v1): export/import operate on the FLAT single-owner
|
|
15
|
+
* store. A tenant-namespaced layout means the bundle would either miss every
|
|
16
|
+
* tenant's tokens or exfiltrate all of them in one file — refuse instead.
|
|
17
|
+
* Returns the offending dir, or null when the store is flat. */
|
|
18
|
+
export function refuseIfMultiTenant(dir = tenantsDir()) {
|
|
19
|
+
try {
|
|
20
|
+
if (readdirSync(dir).length > 0)
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// absent or unreadable tenants/ = flat store
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
15
28
|
function resolveDeps(deps) {
|
|
16
29
|
return {
|
|
17
30
|
configPath: deps.configPath ?? configFilePath(),
|
|
@@ -48,15 +61,52 @@ export function buildTransferBundle(exportedAt, deps = {}) {
|
|
|
48
61
|
}
|
|
49
62
|
return { manifest: { v: 1, exportedAt, aliases: [...aliasSet].sort() }, config, tokens };
|
|
50
63
|
}
|
|
64
|
+
// Bundle envelope v2: a REAL passphrase KDF. The v1 envelope reused the
|
|
65
|
+
// token store's deriveKey, whose passphrase branch is one unsalted sha256 —
|
|
66
|
+
// an exported bundle could be brute-forced offline (CodeQL
|
|
67
|
+
// js/insufficient-password-hash). scrypt with a per-bundle random salt fixes
|
|
68
|
+
// that; v1 bundles (a prerelease-only format, never produced by any stable
|
|
69
|
+
// release) are refused with a re-export hint rather than kept decryptable.
|
|
70
|
+
const BUNDLE_VERSION = 2;
|
|
71
|
+
const SCRYPT_PARAMS = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
|
|
72
|
+
function bundleKey(passphrase, salt) {
|
|
73
|
+
return scryptSync(passphrase, salt, 32, SCRYPT_PARAMS);
|
|
74
|
+
}
|
|
51
75
|
export function encryptBundle(bundle, passphrase) {
|
|
52
|
-
|
|
76
|
+
const salt = randomBytes(16);
|
|
77
|
+
const iv = randomBytes(12);
|
|
78
|
+
const cipher = createCipheriv('aes-256-gcm', bundleKey(passphrase, salt), iv);
|
|
79
|
+
const data = Buffer.concat([cipher.update(Buffer.from(JSON.stringify(bundle), 'utf8')), cipher.final()]);
|
|
80
|
+
const file = {
|
|
81
|
+
bv: BUNDLE_VERSION,
|
|
82
|
+
salt: salt.toString('base64'),
|
|
83
|
+
iv: iv.toString('base64'),
|
|
84
|
+
tag: cipher.getAuthTag().toString('base64'),
|
|
85
|
+
data: data.toString('base64'),
|
|
86
|
+
};
|
|
87
|
+
return JSON.stringify(file);
|
|
53
88
|
}
|
|
54
89
|
export class BundleDecryptError extends Error {
|
|
55
90
|
}
|
|
56
91
|
export function decryptBundle(contents, passphrase) {
|
|
92
|
+
let file;
|
|
93
|
+
try {
|
|
94
|
+
file = JSON.parse(contents);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throw new BundleDecryptError('The file is not a registry export bundle.');
|
|
98
|
+
}
|
|
99
|
+
if (file.v !== undefined && file.bv === undefined) {
|
|
100
|
+
throw new BundleDecryptError('This bundle uses the retired v1 prerelease format (weak passphrase derivation); re-export it from the source machine with the current version.');
|
|
101
|
+
}
|
|
102
|
+
if (file.bv !== BUNDLE_VERSION || !file.salt || !file.iv || !file.tag || !file.data) {
|
|
103
|
+
throw new BundleDecryptError('The file is not a valid registry export (bad envelope).');
|
|
104
|
+
}
|
|
57
105
|
let obj;
|
|
58
106
|
try {
|
|
59
|
-
|
|
107
|
+
const decipher = createDecipheriv('aes-256-gcm', bundleKey(passphrase, Buffer.from(file.salt, 'base64')), Buffer.from(file.iv, 'base64'));
|
|
108
|
+
decipher.setAuthTag(Buffer.from(file.tag, 'base64'));
|
|
109
|
+
obj = JSON.parse(Buffer.concat([decipher.update(Buffer.from(file.data, 'base64')), decipher.final()]).toString('utf8'));
|
|
60
110
|
}
|
|
61
111
|
catch {
|
|
62
112
|
throw new BundleDecryptError('Could not decrypt the bundle — wrong passphrase or the file is corrupt.');
|
|
@@ -153,6 +203,11 @@ async function resolvePassphrase(prompt) {
|
|
|
153
203
|
}
|
|
154
204
|
}
|
|
155
205
|
export async function runExportCli(argv) {
|
|
206
|
+
const mtDir = refuseIfMultiTenant();
|
|
207
|
+
if (mtDir) {
|
|
208
|
+
console.error(`E_MULTI_TENANT_EXPORT_REFUSED: tenant-namespaced token stores exist under ${mtDir}; account export only operates on a flat single-owner store.`);
|
|
209
|
+
return 2;
|
|
210
|
+
}
|
|
156
211
|
const out = argFlag(argv, '--out');
|
|
157
212
|
if (!out) {
|
|
158
213
|
console.error('Usage: mcp-google-multi account export --out <bundle.enc>');
|
|
@@ -170,6 +225,11 @@ export async function runExportCli(argv) {
|
|
|
170
225
|
return 0;
|
|
171
226
|
}
|
|
172
227
|
export async function runImportCli(argv) {
|
|
228
|
+
const mtDir = refuseIfMultiTenant();
|
|
229
|
+
if (mtDir) {
|
|
230
|
+
console.error(`E_MULTI_TENANT_EXPORT_REFUSED: tenant-namespaced token stores exist under ${mtDir}; account import only operates on a flat single-owner store.`);
|
|
231
|
+
return 2;
|
|
232
|
+
}
|
|
173
233
|
const file = argv[argv.indexOf('import') + 1];
|
|
174
234
|
if (!file || file.startsWith('--')) {
|
|
175
235
|
console.error('Usage: mcp-google-multi account import <bundle.enc> [--replace]');
|
package/dist/registry.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { type Policy } from './write-control.js';
|
|
4
|
+
import { type AccountSet } from './accounts.js';
|
|
4
5
|
import type { ArgShape } from './arg-normalize.js';
|
|
5
6
|
import type { Metrics } from './usage-metrics.js';
|
|
6
7
|
export type Cud = 'read' | 'create' | 'update' | 'delete';
|
|
@@ -37,10 +38,13 @@ export interface CatalogOperation {
|
|
|
37
38
|
* real scope error rather than a bare "not found". */
|
|
38
39
|
unreachable?: true;
|
|
39
40
|
}
|
|
41
|
+
/** The service a tool name belongs to: its prefix, unless overridden. */
|
|
42
|
+
export declare function serviceOf(name: string): string;
|
|
40
43
|
export declare function inferCud(name: string): Cud;
|
|
41
44
|
export declare class ToolRegistry {
|
|
42
45
|
private readonly server;
|
|
43
46
|
private readonly metrics;
|
|
47
|
+
private readonly accounts;
|
|
44
48
|
readonly tools: ToolEntry[];
|
|
45
49
|
readonly policy: Policy;
|
|
46
50
|
readonly registerTool: McpServer['registerTool'];
|
|
@@ -57,9 +61,15 @@ export declare class ToolRegistry {
|
|
|
57
61
|
/** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
|
|
58
62
|
* lazy surface to curated without touching the configured mode. */
|
|
59
63
|
private expanded;
|
|
60
|
-
constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode, metrics?: Metrics | null);
|
|
64
|
+
constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode, metrics?: Metrics | null, accounts?: () => AccountSet);
|
|
61
65
|
registerMeta: McpServer['registerTool'];
|
|
62
66
|
services(): string[];
|
|
67
|
+
/** The registry's OWN alias list: registration-time schema builders (the
|
|
68
|
+
* generated account enums) read this, never the process global. */
|
|
69
|
+
accountAliases(): readonly string[];
|
|
70
|
+
/** The registry's OWN account set: handlers needing an account's email or
|
|
71
|
+
* profile read this, never the process global. */
|
|
72
|
+
accountSet(): AccountSet;
|
|
63
73
|
/** Declared input-schema keys + scalar kinds for one tool (tools/call arg
|
|
64
74
|
* normalization; the kind drives value coercion on renamed keys). */
|
|
65
75
|
argShape(name: string): ArgShape | undefined;
|
|
@@ -118,4 +128,11 @@ export declare class ToolRegistry {
|
|
|
118
128
|
};
|
|
119
129
|
installListHandler(): void;
|
|
120
130
|
private toToolJson;
|
|
131
|
+
/** Advertise the CURRENT alias enum on every tools/list: account validation
|
|
132
|
+
* is (or is becoming) live, so a cached boot-time enum would go stale the
|
|
133
|
+
* moment account_add lands mid-session. The structural schema stays cached;
|
|
134
|
+
* only the account property's values are refreshed per call. Skipped for
|
|
135
|
+
* the subject-taking account tools (their `account` is the OPERAND, e.g. a
|
|
136
|
+
* brand-new alias name, not an identity to enumerate). */
|
|
137
|
+
private withLiveAccountEnum;
|
|
121
138
|
}
|
package/dist/registry.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { isAllowed, writeDisabledResult, IRREVERSIBLE_TOOLS } from './write-control.js';
|
|
3
|
-
import { getAccountSet, refreshAccountSetIfStale } from './accounts.js';
|
|
3
|
+
import { getAccountSet, isLiveAccountField, refreshAccountSetIfStale } from './accounts.js';
|
|
4
4
|
import { compactResult, trimEnabled } from './trim.js';
|
|
5
|
-
import { fanoutAccountField, invalidAccountsResult, parseAccountSelector, runFanout } from './fanout.js';
|
|
5
|
+
import { fanoutAccountField, invalidAccountsResult, noAccountsResult, parseAccountSelector, runFanout } from './fanout.js';
|
|
6
6
|
import { MAX_RESPONSE_CHARS } from './executor.js';
|
|
7
|
-
import { suggestKeys } from './arg-strict.js';
|
|
7
|
+
import { MAX_SUGGESTED_KEYS, suggestKeys } from './arg-strict.js';
|
|
8
8
|
import { classifyScope } from './scope-observability.js';
|
|
9
|
+
import { hints } from './hint-provider.js';
|
|
9
10
|
/** Nothing granted, no profile: asks the BUNDLE CATALOG whether a scope is
|
|
10
11
|
* reachable at all, independently of any account. */
|
|
11
12
|
const EMPTY_SCOPES = new Set();
|
|
@@ -40,6 +41,10 @@ const CUD_OVERRIDES = {
|
|
|
40
41
|
const SERVICE_OVERRIDES = {
|
|
41
42
|
reports_activities_list: 'admin',
|
|
42
43
|
};
|
|
44
|
+
/** The service a tool name belongs to: its prefix, unless overridden. */
|
|
45
|
+
export function serviceOf(name) {
|
|
46
|
+
return Object.hasOwn(SERVICE_OVERRIDES, name) ? SERVICE_OVERRIDES[name] : name.includes('_') ? name.slice(0, name.indexOf('_')) : name;
|
|
47
|
+
}
|
|
43
48
|
// read tools that write local files — same savePath fanned across accounts would clobber
|
|
44
49
|
const FANOUT_EXCLUDE = new Set(['gmail_download_attachment', 'drive_download', 'drive_export']);
|
|
45
50
|
/** Account-management tools: the `account` they take is the SUBJECT of the
|
|
@@ -64,7 +69,11 @@ function scalarKindOf(field) {
|
|
|
64
69
|
}
|
|
65
70
|
return 'other';
|
|
66
71
|
}
|
|
67
|
-
|
|
72
|
+
/** A field naming one of the registry's accounts: a baked enum or a live
|
|
73
|
+
* account check (curated tools and generated tools both validate live). */
|
|
74
|
+
function isAccountSelector(field) {
|
|
75
|
+
if (isLiveAccountField(field))
|
|
76
|
+
return true;
|
|
68
77
|
const def = field?._zod?.def;
|
|
69
78
|
if (!def)
|
|
70
79
|
return false;
|
|
@@ -91,6 +100,7 @@ export function inferCud(name) {
|
|
|
91
100
|
export class ToolRegistry {
|
|
92
101
|
server;
|
|
93
102
|
metrics;
|
|
103
|
+
accounts;
|
|
94
104
|
tools = [];
|
|
95
105
|
policy;
|
|
96
106
|
registerTool;
|
|
@@ -107,13 +117,18 @@ export class ToolRegistry {
|
|
|
107
117
|
/** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
|
|
108
118
|
* lazy surface to curated without touching the configured mode. */
|
|
109
119
|
expanded = false;
|
|
110
|
-
constructor(server, policy, mode = resolveDiscoveryMode(), metrics = null
|
|
120
|
+
constructor(server, policy, mode = resolveDiscoveryMode(), metrics = null,
|
|
121
|
+
// The registry's OWN account view: fan-out expansion, selector validation
|
|
122
|
+
// and default-account injection all read through it so a registry built
|
|
123
|
+
// over a subset never observes (or leaks) the global set.
|
|
124
|
+
accounts = getAccountSet) {
|
|
111
125
|
this.server = server;
|
|
112
126
|
this.metrics = metrics;
|
|
127
|
+
this.accounts = accounts;
|
|
113
128
|
this.policy = policy;
|
|
114
129
|
this.mode = mode;
|
|
115
130
|
this.registerTool = ((name, config, handler) => {
|
|
116
|
-
const service =
|
|
131
|
+
const service = serviceOf(name);
|
|
117
132
|
const cud = config.cud ?? inferCud(name);
|
|
118
133
|
// destructiveHint=false claims "additive only" (MCP spec) — updates overwrite, so they stay true.
|
|
119
134
|
// idempotent: reads trivially, deletes (already-gone = same), updates
|
|
@@ -140,14 +155,16 @@ export class ToolRegistry {
|
|
|
140
155
|
let inputShape = config.inputSchema ?? {};
|
|
141
156
|
let baseHandler = handler;
|
|
142
157
|
const hasAccountField = 'account' in inputShape && !DEFAULT_ACCOUNT_EXCLUDE.has(name);
|
|
143
|
-
if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) &&
|
|
158
|
+
if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) && isAccountSelector(inputShape.account)) {
|
|
144
159
|
const description = inputShape.account.description ?? 'Google account alias';
|
|
145
|
-
inputShape = { ...inputShape, account: fanoutAccountField(description) };
|
|
160
|
+
inputShape = { ...inputShape, account: fanoutAccountField(description, () => this.accounts().aliases) };
|
|
146
161
|
baseHandler = async (...args) => {
|
|
147
162
|
const first = args[0];
|
|
148
|
-
const parsed = parseAccountSelector(typeof first?.account === 'string' ? first.account : '');
|
|
163
|
+
const parsed = parseAccountSelector(typeof first?.account === 'string' ? first.account : '', this.accounts().aliases);
|
|
149
164
|
if (!parsed.ok)
|
|
150
|
-
return invalidAccountsResult(parsed.invalid);
|
|
165
|
+
return invalidAccountsResult(parsed.invalid, this.accounts().aliases);
|
|
166
|
+
if (parsed.aliases.length === 0)
|
|
167
|
+
return noAccountsResult();
|
|
151
168
|
if (!parsed.fanout)
|
|
152
169
|
return handler({ ...first, account: parsed.aliases[0] }, ...args.slice(1));
|
|
153
170
|
return runFanout(handler, args, parsed.aliases);
|
|
@@ -188,8 +205,10 @@ export class ToolRegistry {
|
|
|
188
205
|
// heals for a client that always omits account (this branch never
|
|
189
206
|
// reaches getClient's probe). One stat, only on omission.
|
|
190
207
|
refreshAccountSetIfStale();
|
|
191
|
-
const def =
|
|
208
|
+
const def = this.accounts().defaultAccount;
|
|
192
209
|
if (!def) {
|
|
210
|
+
const aliases = this.accounts().aliases;
|
|
211
|
+
const rendered = hints()?.render({ kind: 'no_default_account', aliases });
|
|
193
212
|
return {
|
|
194
213
|
content: [
|
|
195
214
|
{
|
|
@@ -197,7 +216,7 @@ export class ToolRegistry {
|
|
|
197
216
|
text: JSON.stringify({
|
|
198
217
|
error: 'E_NO_DEFAULT_ACCOUNT',
|
|
199
218
|
message: 'No "account" given and no default account is configured.',
|
|
200
|
-
hint: `Pass account explicitly (valid: ${
|
|
219
|
+
hint: rendered?.hint ?? `Pass account explicitly (valid: ${aliases.join(', ')}), or set GOOGLE_DEFAULT_ACCOUNT / "defaultAccount" in config.json.`,
|
|
201
220
|
retriable: false,
|
|
202
221
|
}),
|
|
203
222
|
},
|
|
@@ -219,7 +238,7 @@ export class ToolRegistry {
|
|
|
219
238
|
const v = a?.account;
|
|
220
239
|
if (typeof v !== 'string' || (v !== '*' && !v.includes(',')))
|
|
221
240
|
return 1;
|
|
222
|
-
const sel = parseAccountSelector(v);
|
|
241
|
+
const sel = parseAccountSelector(v, this.accounts().aliases);
|
|
223
242
|
return sel.ok ? sel.aliases.length : 1;
|
|
224
243
|
})
|
|
225
244
|
: finalHandler;
|
|
@@ -239,6 +258,16 @@ export class ToolRegistry {
|
|
|
239
258
|
services() {
|
|
240
259
|
return [...new Set(this.tools.filter((t) => !t.meta).map((t) => t.service))];
|
|
241
260
|
}
|
|
261
|
+
/** The registry's OWN alias list: registration-time schema builders (the
|
|
262
|
+
* generated account enums) read this, never the process global. */
|
|
263
|
+
accountAliases() {
|
|
264
|
+
return this.accounts().aliases;
|
|
265
|
+
}
|
|
266
|
+
/** The registry's OWN account set: handlers needing an account's email or
|
|
267
|
+
* profile read this, never the process global. */
|
|
268
|
+
accountSet() {
|
|
269
|
+
return this.accounts();
|
|
270
|
+
}
|
|
242
271
|
/** Declared input-schema keys + scalar kinds for one tool (tools/call arg
|
|
243
272
|
* normalization; the kind drives value coercion on renamed keys). */
|
|
244
273
|
argShape(name) {
|
|
@@ -293,7 +322,7 @@ export class ToolRegistry {
|
|
|
293
322
|
// case (parentId against parentFolderId) fails containment and edit
|
|
294
323
|
// distance alike, which is the whole reason that matcher exists.
|
|
295
324
|
const keys = candidates.map(([key]) => key);
|
|
296
|
-
const hits = new Set(unknownKeys.flatMap((k) => suggestKeys(k, keys)));
|
|
325
|
+
const hits = new Set(unknownKeys.slice(0, MAX_SUGGESTED_KEYS).flatMap((k) => suggestKeys(k, keys)));
|
|
297
326
|
return candidates
|
|
298
327
|
.filter(([key]) => hits.has(key))
|
|
299
328
|
.slice(0, 2)
|
|
@@ -440,9 +469,36 @@ export class ToolRegistry {
|
|
|
440
469
|
return {
|
|
441
470
|
name: tool.name,
|
|
442
471
|
description: tool.description,
|
|
443
|
-
inputSchema,
|
|
472
|
+
inputSchema: this.withLiveAccountEnum(tool, inputSchema),
|
|
444
473
|
annotations: tool.annotations,
|
|
445
474
|
...(tool.clientMeta ? { _meta: tool.clientMeta } : {}),
|
|
446
475
|
};
|
|
447
476
|
}
|
|
477
|
+
/** Advertise the CURRENT alias enum on every tools/list: account validation
|
|
478
|
+
* is (or is becoming) live, so a cached boot-time enum would go stale the
|
|
479
|
+
* moment account_add lands mid-session. The structural schema stays cached;
|
|
480
|
+
* only the account property's values are refreshed per call. Skipped for
|
|
481
|
+
* the subject-taking account tools (their `account` is the OPERAND, e.g. a
|
|
482
|
+
* brand-new alias name, not an identity to enumerate). */
|
|
483
|
+
withLiveAccountEnum(tool, schema) {
|
|
484
|
+
if (DEFAULT_ACCOUNT_EXCLUDE.has(tool.name))
|
|
485
|
+
return schema;
|
|
486
|
+
const s = schema;
|
|
487
|
+
const account = s?.properties?.account;
|
|
488
|
+
if (!account)
|
|
489
|
+
return schema;
|
|
490
|
+
const aliases = this.accounts().aliases;
|
|
491
|
+
if (aliases.length === 0)
|
|
492
|
+
return schema;
|
|
493
|
+
if (Array.isArray(account.anyOf)) {
|
|
494
|
+
// fan-out union: advertise '*' + the live aliases on the selector branch
|
|
495
|
+
// (the one without the CSV pattern), keep the CSV branch as is
|
|
496
|
+
const isSelector = (b) => b.pattern === undefined;
|
|
497
|
+
if (!account.anyOf.some(isSelector))
|
|
498
|
+
return schema;
|
|
499
|
+
const anyOf = account.anyOf.map((b) => (isSelector(b) ? { ...b, enum: ['*', ...aliases] } : b));
|
|
500
|
+
return { ...s, properties: { ...s.properties, account: { ...account, anyOf } } };
|
|
501
|
+
}
|
|
502
|
+
return { ...s, properties: { ...s.properties, account: { ...account, enum: [...aliases] } } };
|
|
503
|
+
}
|
|
448
504
|
}
|
package/dist/scope-catalog.d.ts
CHANGED
|
@@ -14,7 +14,18 @@ export declare const BUNDLE_CATALOG: Record<string, BundleCatalogEntry>;
|
|
|
14
14
|
/** One-way soft-rename map (UD5): empty at 6.0.0; the mechanism ships now so a
|
|
15
15
|
* future rename is a deprecation warning, never a breaking config change. */
|
|
16
16
|
export declare const BUNDLE_ALIASES: Record<string, string>;
|
|
17
|
+
/** Bundle names are user input: an Object.prototype member ("constructor",
|
|
18
|
+
* "toString") passes a plain `in` or index lookup, so membership is own-key only. */
|
|
19
|
+
export declare function isKnownBundle(name: string): boolean;
|
|
17
20
|
export declare function resolveBundleAliases(bundles: string[]): string[];
|
|
18
21
|
/** Closest catalog key for E_UNKNOWN_BUNDLE remediation (edit distance <= 2). */
|
|
19
22
|
export declare function closestBundle(name: string): string | undefined;
|
|
23
|
+
/** Longest input any did-you-mean compares. Every name it suggests (tool,
|
|
24
|
+
* argument key, bundle, Discovery method id) is far shorter, and the inputs
|
|
25
|
+
* are caller-controlled: an unbounded comparison let one oversized name block
|
|
26
|
+
* the event loop for minutes. */
|
|
27
|
+
export declare const SUGGEST_MAX_INPUT = 128;
|
|
28
|
+
/** Levenshtein distance. Past SUGGEST_MAX_INPUT it returns the longer length
|
|
29
|
+
* without comparing: an upper bound on the true distance, and above every
|
|
30
|
+
* caller's accept threshold, so a capped pair is never suggested. */
|
|
20
31
|
export declare function editDistance(a: string, b: string): number;
|
package/dist/scope-catalog.js
CHANGED
|
@@ -160,9 +160,14 @@ export const BUNDLE_CATALOG = {
|
|
|
160
160
|
* future rename is a deprecation warning, never a breaking config change. */
|
|
161
161
|
export const BUNDLE_ALIASES = {};
|
|
162
162
|
const warnedAliases = new Set();
|
|
163
|
+
/** Bundle names are user input: an Object.prototype member ("constructor",
|
|
164
|
+
* "toString") passes a plain `in` or index lookup, so membership is own-key only. */
|
|
165
|
+
export function isKnownBundle(name) {
|
|
166
|
+
return Object.hasOwn(BUNDLE_CATALOG, name);
|
|
167
|
+
}
|
|
163
168
|
export function resolveBundleAliases(bundles) {
|
|
164
169
|
return bundles.map((name) => {
|
|
165
|
-
const target = BUNDLE_ALIASES[name];
|
|
170
|
+
const target = Object.hasOwn(BUNDLE_ALIASES, name) ? BUNDLE_ALIASES[name] : undefined;
|
|
166
171
|
if (!target)
|
|
167
172
|
return name;
|
|
168
173
|
if (!warnedAliases.has(name)) {
|
|
@@ -177,6 +182,8 @@ export function closestBundle(name) {
|
|
|
177
182
|
let best;
|
|
178
183
|
let bestDist = 3;
|
|
179
184
|
for (const key of Object.keys(BUNDLE_CATALOG)) {
|
|
185
|
+
if (Math.abs(name.length - key.length) >= bestDist)
|
|
186
|
+
continue;
|
|
180
187
|
const d = editDistance(name.toLowerCase(), key);
|
|
181
188
|
if (d < bestDist) {
|
|
182
189
|
bestDist = d;
|
|
@@ -185,14 +192,24 @@ export function closestBundle(name) {
|
|
|
185
192
|
}
|
|
186
193
|
return best;
|
|
187
194
|
}
|
|
195
|
+
/** Longest input any did-you-mean compares. Every name it suggests (tool,
|
|
196
|
+
* argument key, bundle, Discovery method id) is far shorter, and the inputs
|
|
197
|
+
* are caller-controlled: an unbounded comparison let one oversized name block
|
|
198
|
+
* the event loop for minutes. */
|
|
199
|
+
export const SUGGEST_MAX_INPUT = 128;
|
|
200
|
+
/** Levenshtein distance. Past SUGGEST_MAX_INPUT it returns the longer length
|
|
201
|
+
* without comparing: an upper bound on the true distance, and above every
|
|
202
|
+
* caller's accept threshold, so a capped pair is never suggested. */
|
|
188
203
|
export function editDistance(a, b) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
204
|
+
if (a.length > SUGGEST_MAX_INPUT || b.length > SUGGEST_MAX_INPUT)
|
|
205
|
+
return Math.max(a.length, b.length);
|
|
206
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
192
207
|
for (let i = 1; i <= a.length; i++) {
|
|
208
|
+
const row = [i];
|
|
193
209
|
for (let j = 1; j <= b.length; j++) {
|
|
194
|
-
|
|
210
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
195
211
|
}
|
|
212
|
+
prev = row;
|
|
196
213
|
}
|
|
197
|
-
return
|
|
214
|
+
return prev[b.length];
|
|
198
215
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { resolveScopesForAccount } from './auth.js';
|
|
2
1
|
import { readToken } from './token-store.js';
|
|
3
2
|
export type ScopeState = 'callable' | 'requestable_not_granted' | 'not_requestable';
|
|
4
3
|
export type NotRequestableReason = 'add_bundle' | 'account_type' | 'unknown_scope';
|
|
@@ -35,12 +34,15 @@ export interface AccountScopeSets {
|
|
|
35
34
|
granted: Set<string>;
|
|
36
35
|
profile: Set<string>;
|
|
37
36
|
}
|
|
37
|
+
/** Where a scope hint reads the granted token and the configured profile;
|
|
38
|
+
* absent = the single-owner token store and global registry. */
|
|
39
|
+
export interface ScopeSetDeps {
|
|
40
|
+
readTokenFn?: typeof readToken;
|
|
41
|
+
profileFn?: (alias: string) => string[];
|
|
42
|
+
}
|
|
38
43
|
/** Best-effort per-account sets; null when the token/profile is unreadable
|
|
39
44
|
* (decrypt error, missing token) — callers fall back to generic hints. */
|
|
40
|
-
export declare function accountScopeSets(alias: string, deps?:
|
|
41
|
-
readTokenFn?: typeof readToken;
|
|
42
|
-
profileFn?: typeof resolveScopesForAccount;
|
|
43
|
-
}): AccountScopeSets | null;
|
|
45
|
+
export declare function accountScopeSets(alias: string, deps?: ScopeSetDeps): AccountScopeSets | null;
|
|
44
46
|
/**
|
|
45
47
|
* Runtime enrichment for a scope 403 on a method whose required scopes are
|
|
46
48
|
* known (escape hatch: runtime Discovery; generated tools: baked). The
|
|
@@ -48,7 +50,7 @@ export declare function accountScopeSets(alias: string, deps?: {
|
|
|
48
50
|
* profile means Google refuses it for this account type (personal Gmail),
|
|
49
51
|
* so re-auth would loop — report the dead end instead.
|
|
50
52
|
*/
|
|
51
|
-
export declare function scopeHintForMethod(required: readonly string[], alias: string, deps?:
|
|
53
|
+
export declare function scopeHintForMethod(required: readonly string[], alias: string, deps?: ScopeSetDeps): {
|
|
52
54
|
hint: string;
|
|
53
55
|
retriable: boolean;
|
|
54
56
|
} | null;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { ADMIN_SCOPES, BUNDLE_CATALOG } from './scope-catalog.js';
|
|
2
2
|
import { BASE_SCOPES, resolveScopesForAccount } from './auth.js';
|
|
3
3
|
import { readToken } from './token-store.js';
|
|
4
|
+
import { reauthHint } from './reauth-hint.js';
|
|
5
|
+
import { hints } from './hint-provider.js';
|
|
4
6
|
// Reverse index scope -> bundles offering it, narrowest bundle first (fewest
|
|
5
7
|
// scopes) so the remediation names the smallest grant. BASE_SCOPES map to the
|
|
6
8
|
// pseudo-bundle "base" (outside a profile only via includesBase:false).
|
|
@@ -71,40 +73,54 @@ export function classifyMethodScopes(alternatives, granted, profile) {
|
|
|
71
73
|
*/
|
|
72
74
|
export function scopeHint(scope, c, alias) {
|
|
73
75
|
switch (c.state) {
|
|
74
|
-
case 'requestable_not_granted':
|
|
76
|
+
case 'requestable_not_granted': {
|
|
77
|
+
const rendered = hints()?.render({ kind: 'scope_not_granted', account: alias, scope });
|
|
75
78
|
return {
|
|
76
|
-
hint:
|
|
77
|
-
`
|
|
78
|
-
|
|
79
|
+
hint: rendered?.hint ??
|
|
80
|
+
(`Scope ${scope} is in ${alias}'s profile but not granted (you unchecked it at consent). ` +
|
|
81
|
+
reauthHint(alias)),
|
|
82
|
+
// The ONE kind whose retriable the host may flip (Decision 3): the
|
|
83
|
+
// owner keeps true, a multi-tenant host may report false because the
|
|
84
|
+
// fix needs the user in a browser.
|
|
85
|
+
retriable: rendered?.retriable ?? true,
|
|
79
86
|
};
|
|
87
|
+
}
|
|
80
88
|
case 'not_requestable':
|
|
81
89
|
if (c.reason === 'add_bundle') {
|
|
82
90
|
// "base" is a pseudo-bundle (BASE_SCOPES excluded by includesBase:
|
|
83
91
|
// false), NOT a catalog key — telling users to add it to bundles[]
|
|
84
92
|
// would fail boot with E_UNKNOWN_BUNDLE.
|
|
85
93
|
if (c.bundle === 'base') {
|
|
94
|
+
const rendered = hints()?.render({ kind: 'scope_base_excluded', account: alias, scope });
|
|
86
95
|
return {
|
|
87
|
-
hint:
|
|
88
|
-
`
|
|
96
|
+
hint: rendered?.hint ??
|
|
97
|
+
(`Scope ${scope} is a base scope excluded by "includesBase": false in ${alias}'s profile. ` +
|
|
98
|
+
`Set includesBase back to true (or remove it) in config.json, then npx mcp-google-multi auth --account ${alias}`),
|
|
89
99
|
retriable: false,
|
|
90
100
|
};
|
|
91
101
|
}
|
|
102
|
+
const rendered = hints()?.render({ kind: 'scope_needs_bundle', account: alias, scope, bundle: c.bundle });
|
|
92
103
|
return {
|
|
93
|
-
hint:
|
|
94
|
-
`
|
|
104
|
+
hint: rendered?.hint ??
|
|
105
|
+
(`Scope ${scope} needs the "${c.bundle}" bundle, which is not in ${alias}'s profile. ` +
|
|
106
|
+
`Add it: set ${alias}'s profile bundles to include "${c.bundle}" in config.json, then npx mcp-google-multi auth --account ${alias}`),
|
|
95
107
|
retriable: false,
|
|
96
108
|
};
|
|
97
109
|
}
|
|
98
110
|
if (c.reason === 'account_type') {
|
|
111
|
+
const rendered = hints()?.render({ kind: 'scope_account_type', account: alias, scope });
|
|
99
112
|
return {
|
|
100
|
-
hint: `Scope ${scope} requires a Google Workspace account; ${alias} is a personal account and will 403. Use a Workspace alias. Do not retry.`,
|
|
113
|
+
hint: rendered?.hint ?? `Scope ${scope} requires a Google Workspace account; ${alias} is a personal account and will 403. Use a Workspace alias. Do not retry.`,
|
|
114
|
+
retriable: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
{
|
|
118
|
+
const rendered = hints()?.render({ kind: 'scope_unknown_scope', scope });
|
|
119
|
+
return {
|
|
120
|
+
hint: rendered?.hint ?? `Scope ${scope} is not offered by this server (no bundle grants it). Not requestable. Do not retry.`,
|
|
101
121
|
retriable: false,
|
|
102
122
|
};
|
|
103
123
|
}
|
|
104
|
-
return {
|
|
105
|
-
hint: `Scope ${scope} is not offered by this server (no bundle grants it). Not requestable. Do not retry.`,
|
|
106
|
-
retriable: false,
|
|
107
|
-
};
|
|
108
124
|
default:
|
|
109
125
|
return { hint: '', retriable: true };
|
|
110
126
|
}
|