mcp-google-multi 6.0.0-beta.2 → 6.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/accounts.d.ts +20 -4
- package/dist/accounts.js +68 -44
- package/dist/api-probe.d.ts +5 -0
- package/dist/api-probe.js +20 -15
- package/dist/arg-normalize.js +19 -38
- package/dist/arg-strict.d.ts +7 -0
- package/dist/arg-strict.js +38 -28
- package/dist/auth.d.ts +5 -4
- package/dist/auth.js +16 -23
- package/dist/boot-gates.d.ts +26 -0
- package/dist/boot-gates.js +36 -0
- package/dist/cimd-cache.d.ts +38 -0
- package/dist/cimd-cache.js +111 -0
- package/dist/client-config.d.ts +2 -2
- package/dist/client-config.js +8 -14
- package/dist/client.d.ts +32 -2
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +18 -0
- package/dist/compose.js +109 -0
- package/dist/config-file.d.ts +13 -0
- package/dist/config-file.js +41 -10
- package/dist/discover.js +7 -10
- package/dist/discovery-client.d.ts +3 -0
- package/dist/discovery-client.js +36 -32
- package/dist/doctor.d.ts +9 -5
- package/dist/doctor.js +119 -29
- package/dist/env-load.js +2 -5
- package/dist/executor.d.ts +3 -0
- package/dist/executor.js +6 -6
- package/dist/fanout.d.ts +9 -1
- package/dist/fanout.js +47 -22
- package/dist/fs-atomic.d.ts +8 -0
- package/dist/fs-atomic.js +216 -29
- package/dist/hint-provider.d.ts +129 -0
- package/dist/hint-provider.js +58 -0
- package/dist/http-config.d.ts +2 -2
- package/dist/http-config.js +3 -7
- package/dist/http-transport.d.ts +67 -9
- package/dist/http-transport.js +318 -52
- package/dist/identity.d.ts +6 -3
- package/dist/identity.js +24 -8
- package/dist/index.js +76 -160
- package/dist/master-key.d.ts +3 -3
- package/dist/master-key.js +16 -19
- package/dist/mcp-token.d.ts +126 -19
- package/dist/mcp-token.js +557 -76
- package/dist/metrics-cli.d.ts +1 -1
- package/dist/metrics-cli.js +1 -1
- package/dist/metrics-tap.js +2 -5
- package/dist/migrate-config.d.ts +1 -2
- package/dist/migrate-config.js +5 -7
- package/dist/net-tuning.js +2 -4
- package/dist/oauth-as.d.ts +104 -4
- package/dist/oauth-as.js +368 -114
- package/dist/oauth-consent.d.ts +10 -0
- package/dist/oauth-consent.js +20 -10
- package/dist/outbound-allowlist.js +9 -11
- package/dist/reauth-hint.d.ts +1 -1
- package/dist/reauth-hint.js +11 -10
- package/dist/registry-transfer.d.ts +5 -0
- package/dist/registry-transfer.js +63 -11
- package/dist/registry.d.ts +13 -4
- package/dist/registry.js +100 -72
- package/dist/scope-catalog.d.ts +16 -0
- package/dist/scope-catalog.js +40 -13
- package/dist/scope-observability.d.ts +10 -9
- package/dist/scope-observability.js +34 -24
- package/dist/services.d.ts +18 -10
- package/dist/services.js +61 -22
- package/dist/ssrf-guard.d.ts +23 -9
- package/dist/ssrf-guard.js +106 -78
- package/dist/tenant-purge.d.ts +26 -0
- package/dist/tenant-purge.js +65 -0
- package/dist/token-store.d.ts +20 -2
- package/dist/token-store.js +0 -0
- package/dist/tools/_coerce.d.ts +4 -4
- package/dist/tools/_errors.d.ts +3 -0
- package/dist/tools/_errors.js +72 -60
- package/dist/tools/_local-files.d.ts +9 -0
- package/dist/tools/_local-files.js +22 -8
- package/dist/tools/account-wizard.d.ts +13 -3
- package/dist/tools/account-wizard.js +107 -56
- package/dist/tools/accounts-tool.d.ts +8 -1
- package/dist/tools/accounts-tool.js +35 -19
- package/dist/tools/admin.d.ts +10 -1
- package/dist/tools/admin.js +14 -11
- package/dist/tools/analytics.d.ts +10 -1
- package/dist/tools/analytics.js +14 -10
- package/dist/tools/calendar.d.ts +2 -1
- package/dist/tools/calendar.js +19 -18
- package/dist/tools/chat.d.ts +10 -1
- package/dist/tools/chat.js +12 -8
- package/dist/tools/contacts.d.ts +2 -1
- package/dist/tools/contacts.js +22 -19
- package/dist/tools/docs.d.ts +2 -1
- package/dist/tools/docs.js +35 -36
- package/dist/tools/drive.d.ts +2 -1
- package/dist/tools/drive.js +80 -74
- package/dist/tools/forms.d.ts +10 -1
- package/dist/tools/forms.js +18 -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-mime.d.ts +1 -1
- package/dist/tools/gmail-mime.js +7 -12
- package/dist/tools/gmail.d.ts +4 -3
- package/dist/tools/gmail.js +67 -69
- package/dist/tools/google-api.d.ts +2 -3
- package/dist/tools/google-api.js +34 -22
- package/dist/tools/meet.d.ts +10 -1
- package/dist/tools/meet.js +12 -9
- package/dist/tools/searchconsole.d.ts +2 -1
- package/dist/tools/searchconsole.js +16 -13
- package/dist/tools/sheets.d.ts +2 -1
- package/dist/tools/sheets.js +35 -32
- package/dist/tools/slides.d.ts +10 -1
- package/dist/tools/slides.js +16 -10
- package/dist/tools/tasks.d.ts +2 -1
- package/dist/tools/tasks.js +18 -15
- package/dist/trim.d.ts +5 -0
- package/dist/trim.js +19 -0
- package/dist/usage-metrics.d.ts +23 -0
- package/dist/usage-metrics.js +67 -56
- package/dist/write-control.d.ts +4 -8
- package/dist/write-control.js +13 -6
- package/package.json +47 -21
package/dist/registry.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
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();
|
|
12
|
-
// Client-side result budget
|
|
13
|
-
//
|
|
14
|
-
// context limit while leaving room for real list payloads.
|
|
13
|
+
// Client-side result budget for tools that do not declare their own (fat
|
|
14
|
+
// readers do; see trim.ts); stays well inside a default client context limit.
|
|
15
15
|
const DEFAULT_MAX_RESULT_CHARS = 50_000;
|
|
16
16
|
const DISCOVERY_MODES = ['lazy', 'curated', 'eager'];
|
|
17
17
|
export function resolveDiscoveryMode(env = process.env) {
|
|
@@ -28,9 +28,8 @@ const CUD_OVERRIDES = {
|
|
|
28
28
|
drive_transfer: 'create',
|
|
29
29
|
// read-only resolver; the name's "resolve" verb would otherwise infer update.
|
|
30
30
|
contacts_resolve: 'read',
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// works under any profile. cud=read also keeps them off the fan-out path.
|
|
31
|
+
// Gated by anthropic/requiresUserInteraction, not write-control, so
|
|
32
|
+
// onboarding works under any profile; cud=read also keeps them off fan-out.
|
|
34
33
|
account_add: 'read',
|
|
35
34
|
account_reauth: 'read',
|
|
36
35
|
// Writes a LOCAL MCP-client config, not Google data; gated by
|
|
@@ -40,12 +39,17 @@ const CUD_OVERRIDES = {
|
|
|
40
39
|
const SERVICE_OVERRIDES = {
|
|
41
40
|
reports_activities_list: 'admin',
|
|
42
41
|
};
|
|
42
|
+
/** The service a tool name belongs to: its prefix, unless overridden. */
|
|
43
|
+
export function serviceOf(name) {
|
|
44
|
+
return Object.hasOwn(SERVICE_OVERRIDES, name) ? SERVICE_OVERRIDES[name] : name.includes('_') ? name.slice(0, name.indexOf('_')) : name;
|
|
45
|
+
}
|
|
43
46
|
// read tools that write local files — same savePath fanned across accounts would clobber
|
|
44
47
|
const FANOUT_EXCLUDE = new Set(['gmail_download_attachment', 'drive_download', 'drive_export']);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
// cud=read for gating/fan-out, yet they write (local files, the account
|
|
49
|
+
// registry, config): advertised as writes so annotation-keyed clients prompt.
|
|
50
|
+
const WRITES_DESPITE_READ = new Set([...FANOUT_EXCLUDE, 'account_add', 'account_reauth', 'account_write_config']);
|
|
51
|
+
// Their `account` is the SUBJECT of the operation (e.g. the new alias to add),
|
|
52
|
+
// not the identity to run as, so injecting the configured default would retarget them.
|
|
49
53
|
const DEFAULT_ACCOUNT_EXCLUDE = new Set(['account_add', 'account_reauth']);
|
|
50
54
|
/** Unwrap optional/default/nullable to the declared scalar kind (zod 4 defs). */
|
|
51
55
|
function scalarKindOf(field) {
|
|
@@ -64,11 +68,15 @@ function scalarKindOf(field) {
|
|
|
64
68
|
}
|
|
65
69
|
return 'other';
|
|
66
70
|
}
|
|
67
|
-
|
|
71
|
+
/** A field naming one of the registry's accounts: a baked enum or a live
|
|
72
|
+
* account check (curated tools and generated tools both validate live). */
|
|
73
|
+
function isAccountSelector(field) {
|
|
74
|
+
if (isLiveAccountField(field))
|
|
75
|
+
return true;
|
|
68
76
|
const def = field?._zod?.def;
|
|
69
77
|
if (!def)
|
|
70
78
|
return false;
|
|
71
|
-
//
|
|
79
|
+
// Account enums are .optional(); unwrap it or fan-out silently dies.
|
|
72
80
|
if (def.type === 'optional')
|
|
73
81
|
return def.innerType?._zod?.def?.type === 'enum';
|
|
74
82
|
return def.type === 'enum';
|
|
@@ -91,14 +99,14 @@ export function inferCud(name) {
|
|
|
91
99
|
export class ToolRegistry {
|
|
92
100
|
server;
|
|
93
101
|
metrics;
|
|
102
|
+
accounts;
|
|
94
103
|
tools = [];
|
|
95
104
|
policy;
|
|
96
105
|
registerTool;
|
|
97
106
|
revealed = new Set();
|
|
98
107
|
jsonSchemaCache = new Map();
|
|
99
108
|
argShapeCache = new Map();
|
|
100
|
-
|
|
101
|
-
* bundle catalog only, so it never changes within a process. */
|
|
109
|
+
// Memoized isUngrantable verdict: depends on the frozen bundle catalog only.
|
|
102
110
|
ungrantable = new Map();
|
|
103
111
|
compactOutput = trimEnabled();
|
|
104
112
|
registeringMeta = false;
|
|
@@ -107,30 +115,29 @@ export class ToolRegistry {
|
|
|
107
115
|
/** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
|
|
108
116
|
* lazy surface to curated without touching the configured mode. */
|
|
109
117
|
expanded = false;
|
|
110
|
-
constructor(server, policy, mode = resolveDiscoveryMode(), metrics = null
|
|
118
|
+
constructor(server, policy, mode = resolveDiscoveryMode(), metrics = null,
|
|
119
|
+
// The registry's OWN account view, so one built over a subset never
|
|
120
|
+
// observes (or leaks) the global set.
|
|
121
|
+
accounts = getAccountSet) {
|
|
111
122
|
this.server = server;
|
|
112
123
|
this.metrics = metrics;
|
|
124
|
+
this.accounts = accounts;
|
|
113
125
|
this.policy = policy;
|
|
114
126
|
this.mode = mode;
|
|
115
127
|
this.registerTool = ((name, config, handler) => {
|
|
116
|
-
const service =
|
|
128
|
+
const service = serviceOf(name);
|
|
117
129
|
const cud = config.cud ?? inferCud(name);
|
|
118
|
-
//
|
|
119
|
-
// idempotent
|
|
120
|
-
|
|
130
|
+
// Every write is destructive (a create is no less consequential than an
|
|
131
|
+
// update); idempotent iff not a create (send twice = two emails).
|
|
132
|
+
const writes = cud !== 'read' || WRITES_DESPITE_READ.has(name);
|
|
121
133
|
const annotations = {
|
|
122
|
-
readOnlyHint:
|
|
123
|
-
destructiveHint:
|
|
134
|
+
readOnlyHint: !writes,
|
|
135
|
+
destructiveHint: writes,
|
|
124
136
|
idempotentHint: cud !== 'create',
|
|
125
137
|
...config.annotations,
|
|
126
138
|
};
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
// anthropic/* ONLY there); the server verdict stays separate.
|
|
130
|
-
// Every tool also advertises a result-size budget: fat readers declare
|
|
131
|
-
// their own, everything else inherits the default, so an uncapped tool
|
|
132
|
-
// can never blow past a client's context limit. Generated tools align
|
|
133
|
-
// with the executor's server-side cap.
|
|
139
|
+
// Irreversible-set approval and the result-size budget are both
|
|
140
|
+
// client-enforced via the wire _meta (anthropic/*); the server verdict stays separate.
|
|
134
141
|
const clientMeta = {
|
|
135
142
|
'anthropic/maxResultSizeChars': config.cud !== undefined ? MAX_RESPONSE_CHARS : DEFAULT_MAX_RESULT_CHARS,
|
|
136
143
|
...config._meta,
|
|
@@ -140,14 +147,16 @@ export class ToolRegistry {
|
|
|
140
147
|
let inputShape = config.inputSchema ?? {};
|
|
141
148
|
let baseHandler = handler;
|
|
142
149
|
const hasAccountField = 'account' in inputShape && !DEFAULT_ACCOUNT_EXCLUDE.has(name);
|
|
143
|
-
if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) &&
|
|
150
|
+
if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) && isAccountSelector(inputShape.account)) {
|
|
144
151
|
const description = inputShape.account.description ?? 'Google account alias';
|
|
145
|
-
inputShape = { ...inputShape, account: fanoutAccountField(description) };
|
|
152
|
+
inputShape = { ...inputShape, account: fanoutAccountField(description, () => this.accounts().aliases) };
|
|
146
153
|
baseHandler = async (...args) => {
|
|
147
154
|
const first = args[0];
|
|
148
|
-
const parsed = parseAccountSelector(typeof first?.account === 'string' ? first.account : '');
|
|
155
|
+
const parsed = parseAccountSelector(typeof first?.account === 'string' ? first.account : '', this.accounts().aliases);
|
|
149
156
|
if (!parsed.ok)
|
|
150
|
-
return invalidAccountsResult(parsed.invalid);
|
|
157
|
+
return invalidAccountsResult(parsed.invalid, this.accounts().aliases);
|
|
158
|
+
if (parsed.aliases.length === 0)
|
|
159
|
+
return noAccountsResult();
|
|
151
160
|
if (!parsed.fanout)
|
|
152
161
|
return handler({ ...first, account: parsed.aliases[0] }, ...args.slice(1));
|
|
153
162
|
return runFanout(handler, args, parsed.aliases);
|
|
@@ -173,10 +182,8 @@ export class ToolRegistry {
|
|
|
173
182
|
: writeDisabledResult({ name, service, cud, scopes: config.requiredScopes }, policy,
|
|
174
183
|
// Concrete by here: default-account injection wraps this.
|
|
175
184
|
args[0]?.account);
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
// (that is what covers google_api_call with zero bespoke code). Explicit
|
|
179
|
-
// aliases, "*" and CSV pass through untouched.
|
|
185
|
+
// The ONE default-account injection site, outside the CUD gate and the
|
|
186
|
+
// fan-out parse (not meta-skipped, which is what covers google_api_call).
|
|
180
187
|
const withDefault = !hasAccountField
|
|
181
188
|
? guarded
|
|
182
189
|
: (...args) => {
|
|
@@ -184,12 +191,13 @@ export class ToolRegistry {
|
|
|
184
191
|
const value = first?.account;
|
|
185
192
|
if (value != null && value !== '')
|
|
186
193
|
return guarded(...args);
|
|
187
|
-
// Refresh here or
|
|
188
|
-
//
|
|
189
|
-
// reaches getClient's probe). One stat, only on omission.
|
|
194
|
+
// Refresh here, or a client that always omits account never heals
|
|
195
|
+
// the unset->configured default transition (getClient's probe is unreached).
|
|
190
196
|
refreshAccountSetIfStale();
|
|
191
|
-
const def =
|
|
197
|
+
const def = this.accounts().defaultAccount;
|
|
192
198
|
if (!def) {
|
|
199
|
+
const aliases = this.accounts().aliases;
|
|
200
|
+
const rendered = hints()?.render({ kind: 'no_default_account', aliases });
|
|
193
201
|
return {
|
|
194
202
|
content: [
|
|
195
203
|
{
|
|
@@ -197,7 +205,7 @@ export class ToolRegistry {
|
|
|
197
205
|
text: JSON.stringify({
|
|
198
206
|
error: 'E_NO_DEFAULT_ACCOUNT',
|
|
199
207
|
message: 'No "account" given and no default account is configured.',
|
|
200
|
-
hint: `Pass account explicitly (valid: ${
|
|
208
|
+
hint: rendered?.hint ?? `Pass account explicitly (valid: ${aliases.join(', ')}), or set GOOGLE_DEFAULT_ACCOUNT / "defaultAccount" in config.json.`,
|
|
201
209
|
retriable: false,
|
|
202
210
|
}),
|
|
203
211
|
},
|
|
@@ -210,16 +218,14 @@ export class ToolRegistry {
|
|
|
210
218
|
const finalHandler = this.compactOutput
|
|
211
219
|
? async (...args) => compactResult(await withDefault(...args))
|
|
212
220
|
: withDefault;
|
|
213
|
-
// Usage metrics wrap
|
|
214
|
-
//
|
|
215
|
-
// chain. Fan-out width is derived here (bucketed in the module) so the
|
|
216
|
-
// metrics module never imports fanout.
|
|
221
|
+
// Usage metrics wrap outermost, only when enabled (off = byte-identical
|
|
222
|
+
// to the pre-metrics chain); fan-out width is derived here so the metrics module never imports fanout.
|
|
217
223
|
const instrumented = this.metrics
|
|
218
224
|
? this.metrics.wrap({ name, service, meta: this.registeringMeta, generated: config.cud !== undefined }, finalHandler, (a) => {
|
|
219
225
|
const v = a?.account;
|
|
220
226
|
if (typeof v !== 'string' || (v !== '*' && !v.includes(',')))
|
|
221
227
|
return 1;
|
|
222
|
-
const sel = parseAccountSelector(v);
|
|
228
|
+
const sel = parseAccountSelector(v, this.accounts().aliases);
|
|
223
229
|
return sel.ok ? sel.aliases.length : 1;
|
|
224
230
|
})
|
|
225
231
|
: finalHandler;
|
|
@@ -239,6 +245,16 @@ export class ToolRegistry {
|
|
|
239
245
|
services() {
|
|
240
246
|
return [...new Set(this.tools.filter((t) => !t.meta).map((t) => t.service))];
|
|
241
247
|
}
|
|
248
|
+
/** The registry's OWN alias list: registration-time schema builders (the
|
|
249
|
+
* generated account enums) read this, never the process global. */
|
|
250
|
+
accountAliases() {
|
|
251
|
+
return this.accounts().aliases;
|
|
252
|
+
}
|
|
253
|
+
/** The registry's OWN account set: handlers needing an account's email or
|
|
254
|
+
* profile read this, never the process global. */
|
|
255
|
+
accountSet() {
|
|
256
|
+
return this.accounts();
|
|
257
|
+
}
|
|
242
258
|
/** Declared input-schema keys + scalar kinds for one tool (tools/call arg
|
|
243
259
|
* normalization; the kind drives value coercion on renamed keys). */
|
|
244
260
|
argShape(name) {
|
|
@@ -272,10 +288,8 @@ export class ToolRegistry {
|
|
|
272
288
|
const declared = new Set(Object.keys(self.inputShape));
|
|
273
289
|
const byKey = new Map();
|
|
274
290
|
for (const t of this.tools) {
|
|
275
|
-
// Only tools
|
|
276
|
-
//
|
|
277
|
-
// into a dead end: in curated mode 353 tools are registered and 181 are
|
|
278
|
-
// advertised, and the hint used to reach for any of them.
|
|
291
|
+
// Only tools visible in tools/list: a hint naming a hidden generated
|
|
292
|
+
// tool would turn a recoverable error into a dead end.
|
|
279
293
|
if (t.service !== self.service || t.name === tool || !this.isVisible(t))
|
|
280
294
|
continue;
|
|
281
295
|
for (const key of Object.keys(t.inputShape)) {
|
|
@@ -289,11 +303,10 @@ export class ToolRegistry {
|
|
|
289
303
|
}
|
|
290
304
|
}
|
|
291
305
|
const candidates = [...byKey.entries()].filter(([, e]) => e.tools.length >= 2 || e.curated);
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
// distance alike, which is the whole reason that matcher exists.
|
|
306
|
+
// The tiered matcher, not a substring test: parentId vs parentFolderId
|
|
307
|
+
// fails both containment and edit distance.
|
|
295
308
|
const keys = candidates.map(([key]) => key);
|
|
296
|
-
const hits = new Set(unknownKeys.flatMap((k) => suggestKeys(k, keys)));
|
|
309
|
+
const hits = new Set(unknownKeys.slice(0, MAX_SUGGESTED_KEYS).flatMap((k) => suggestKeys(k, keys)));
|
|
297
310
|
return candidates
|
|
298
311
|
.filter(([key]) => hits.has(key))
|
|
299
312
|
.slice(0, 2)
|
|
@@ -357,7 +370,7 @@ export class ToolRegistry {
|
|
|
357
370
|
return true;
|
|
358
371
|
}
|
|
359
372
|
/** discover_reset: back to the lean meta-only surface. Clears reveals too.
|
|
360
|
-
*
|
|
373
|
+
* If a client mishandles a SHRINKING tools/list, this degrades to a
|
|
361
374
|
* no-op for that session — tools stay callable regardless (graceful
|
|
362
375
|
* dispatch), zero correctness impact. */
|
|
363
376
|
collapse() {
|
|
@@ -387,11 +400,8 @@ export class ToolRegistry {
|
|
|
387
400
|
const cached = this.ungrantable.get(tool.name);
|
|
388
401
|
if (cached !== undefined)
|
|
389
402
|
return cached;
|
|
390
|
-
// EVERY alternative must be ungrantable
|
|
391
|
-
//
|
|
392
|
-
// `not_requestable` rank, so its first-best match hides a grantable
|
|
393
|
-
// alternative that appears later in the list. Discovery scope lists are
|
|
394
|
-
// ANY-OF, so one grantable alternative makes the method reachable.
|
|
403
|
+
// EVERY alternative must be ungrantable (scope lists are ANY-OF); a
|
|
404
|
+
// first-best classifyScope match would hide a grantable one later in the list.
|
|
395
405
|
const verdict = tool.requiredScopes.every((scope) => {
|
|
396
406
|
const c = classifyScope(scope, EMPTY_SCOPES, EMPTY_SCOPES);
|
|
397
407
|
return c.state === 'not_requestable' && c.reason === 'unknown_scope';
|
|
@@ -401,10 +411,8 @@ export class ToolRegistry {
|
|
|
401
411
|
}
|
|
402
412
|
isVisible(tool) {
|
|
403
413
|
const mode = this.effectiveMode();
|
|
404
|
-
//
|
|
405
|
-
// the
|
|
406
|
-
// name still produces the real scope error plus its remediation, and
|
|
407
|
-
// {service}_discover still lists it, marked.
|
|
414
|
+
// Hidden, not unregistered: calling an ungrantable tool by name still
|
|
415
|
+
// produces the real scope error, and {service}_discover still lists it, marked.
|
|
408
416
|
if (!tool.meta && this.isUngrantable(tool))
|
|
409
417
|
return false;
|
|
410
418
|
return (tool.meta ||
|
|
@@ -424,10 +432,7 @@ export class ToolRegistry {
|
|
|
424
432
|
throw new Error('installListHandler() requires at least one registered tool');
|
|
425
433
|
}
|
|
426
434
|
this.server.server.setRequestHandler('tools/list', async () => ({
|
|
427
|
-
//
|
|
428
|
-
// anthropic/* _meta keys honoring clients read. z.toJSONSchema emits a
|
|
429
|
-
// valid draft-7 object schema by construction, which the SDK's recursive
|
|
430
|
-
// JSON-Schema type cannot infer from our cached `unknown`.
|
|
435
|
+
// Hand-built: the SDK's own Tool type drops the anthropic/* _meta keys honoring clients read.
|
|
431
436
|
tools: this.tools.filter((t) => this.isVisible(t)).map((t) => this.toToolJson(t)),
|
|
432
437
|
}));
|
|
433
438
|
}
|
|
@@ -440,9 +445,32 @@ export class ToolRegistry {
|
|
|
440
445
|
return {
|
|
441
446
|
name: tool.name,
|
|
442
447
|
description: tool.description,
|
|
443
|
-
inputSchema,
|
|
448
|
+
inputSchema: this.withLiveAccountEnum(tool, inputSchema),
|
|
444
449
|
annotations: tool.annotations,
|
|
445
450
|
...(tool.clientMeta ? { _meta: tool.clientMeta } : {}),
|
|
446
451
|
};
|
|
447
452
|
}
|
|
453
|
+
// Refreshes the account enum's values per call (structural schema stays
|
|
454
|
+
// cached), so a cached boot-time list doesn't go stale once account_add runs mid-session.
|
|
455
|
+
withLiveAccountEnum(tool, schema) {
|
|
456
|
+
if (DEFAULT_ACCOUNT_EXCLUDE.has(tool.name))
|
|
457
|
+
return schema;
|
|
458
|
+
const s = schema;
|
|
459
|
+
const account = s?.properties?.account;
|
|
460
|
+
if (!account)
|
|
461
|
+
return schema;
|
|
462
|
+
const aliases = this.accounts().aliases;
|
|
463
|
+
if (aliases.length === 0)
|
|
464
|
+
return schema;
|
|
465
|
+
if (Array.isArray(account.anyOf)) {
|
|
466
|
+
// fan-out union: advertise '*' + the live aliases on the selector branch
|
|
467
|
+
// (the one without the CSV pattern), keep the CSV branch as is
|
|
468
|
+
const isSelector = (b) => b.pattern === undefined;
|
|
469
|
+
if (!account.anyOf.some(isSelector))
|
|
470
|
+
return schema;
|
|
471
|
+
const anyOf = account.anyOf.map((b) => (isSelector(b) ? { ...b, enum: ['*', ...aliases] } : b));
|
|
472
|
+
return { ...s, properties: { ...s.properties, account: { ...account, anyOf } } };
|
|
473
|
+
}
|
|
474
|
+
return { ...s, properties: { ...s.properties, account: { ...account, enum: [...aliases] } } };
|
|
475
|
+
}
|
|
448
476
|
}
|
package/dist/scope-catalog.d.ts
CHANGED
|
@@ -8,13 +8,29 @@ export interface BundleCatalogEntry {
|
|
|
8
8
|
description: string;
|
|
9
9
|
risk: 'low' | 'medium' | 'high';
|
|
10
10
|
workspaceOnly?: boolean;
|
|
11
|
+
personalRefused?: boolean;
|
|
11
12
|
}
|
|
12
13
|
export declare const ADMIN_SCOPES: string[];
|
|
13
14
|
export declare const BUNDLE_CATALOG: Record<string, BundleCatalogEntry>;
|
|
14
15
|
/** One-way soft-rename map (UD5): empty at 6.0.0; the mechanism ships now so a
|
|
15
16
|
* future rename is a deprecation warning, never a breaking config change. */
|
|
16
17
|
export declare const BUNDLE_ALIASES: Record<string, string>;
|
|
18
|
+
/** Bundle names are user input: an Object.prototype member ("constructor",
|
|
19
|
+
* "toString") passes a plain `in` or index lookup, so membership is own-key only. */
|
|
20
|
+
export declare function isKnownBundle(name: string): boolean;
|
|
21
|
+
/** A gmail.com or googlemail.com address, which is always a personal Google account. */
|
|
22
|
+
export declare function isPersonalGoogleAddress(email: string): boolean;
|
|
23
|
+
/** The bundles in `bundles` that Google refuses on the consent screen of the account at `email`. */
|
|
24
|
+
export declare function refusedBundlesFor(email: string, bundles: readonly string[]): string[];
|
|
17
25
|
export declare function resolveBundleAliases(bundles: string[]): string[];
|
|
18
26
|
/** Closest catalog key for E_UNKNOWN_BUNDLE remediation (edit distance <= 2). */
|
|
19
27
|
export declare function closestBundle(name: string): string | undefined;
|
|
28
|
+
/** Longest input any did-you-mean compares. Every name it suggests (tool,
|
|
29
|
+
* argument key, bundle, Discovery method id) is far shorter, and the inputs
|
|
30
|
+
* are caller-controlled: an unbounded comparison let one oversized name block
|
|
31
|
+
* the event loop for minutes. */
|
|
32
|
+
export declare const SUGGEST_MAX_INPUT = 128;
|
|
33
|
+
/** Levenshtein distance. Past SUGGEST_MAX_INPUT it returns the longer length
|
|
34
|
+
* without comparing: an upper bound on the true distance, and above every
|
|
35
|
+
* caller's accept threshold, so a capped pair is never suggested. */
|
|
20
36
|
export declare function editDistance(a: string, b: string): number;
|
package/dist/scope-catalog.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
// The curated bundle catalog: ONE source of truth
|
|
2
|
-
//
|
|
3
|
-
// public API (config.json references them by name) — renames go through
|
|
4
|
-
// BUNDLE_ALIASES, never in place.
|
|
1
|
+
// The curated bundle catalog: ONE source of truth for the wizard, scope
|
|
2
|
+
// observability, and docs. Keys are a frozen public API; rename via BUNDLE_ALIASES only.
|
|
5
3
|
export const ADMIN_SCOPES = [
|
|
6
4
|
'https://www.googleapis.com/auth/admin.reports.audit.readonly',
|
|
7
5
|
'https://www.googleapis.com/auth/admin.directory.user',
|
|
@@ -63,6 +61,7 @@ export const BUNDLE_CATALOG = {
|
|
|
63
61
|
],
|
|
64
62
|
description: 'Manage Cloud Identity groups and devices across the directory.',
|
|
65
63
|
risk: 'high',
|
|
64
|
+
personalRefused: true,
|
|
66
65
|
},
|
|
67
66
|
cloudsearch: {
|
|
68
67
|
scopes: ['https://www.googleapis.com/auth/cloud_search'],
|
|
@@ -79,6 +78,7 @@ export const BUNDLE_CATALOG = {
|
|
|
79
78
|
scopes: ['https://www.googleapis.com/auth/keep'],
|
|
80
79
|
description: 'Read and edit Google Keep notes.',
|
|
81
80
|
risk: 'low',
|
|
81
|
+
personalRefused: true,
|
|
82
82
|
},
|
|
83
83
|
driveactivity: {
|
|
84
84
|
scopes: ['https://www.googleapis.com/auth/drive.activity.readonly'],
|
|
@@ -113,9 +113,8 @@ export const BUNDLE_CATALOG = {
|
|
|
113
113
|
description: 'Read Google Analytics (GA4): run reports and inspect accounts, properties and their configuration.',
|
|
114
114
|
risk: 'low',
|
|
115
115
|
},
|
|
116
|
-
// Includes readonly so it
|
|
117
|
-
// authorize Data API reads.
|
|
118
|
-
// target for read scopes (scope-observability sorts bundles by size).
|
|
116
|
+
// Includes readonly so it's self-sufficient: analytics.edit alone doesn't
|
|
117
|
+
// authorize Data API reads.
|
|
119
118
|
analytics_write: {
|
|
120
119
|
scopes: [
|
|
121
120
|
'https://www.googleapis.com/auth/analytics.readonly',
|
|
@@ -160,9 +159,25 @@ export const BUNDLE_CATALOG = {
|
|
|
160
159
|
* future rename is a deprecation warning, never a breaking config change. */
|
|
161
160
|
export const BUNDLE_ALIASES = {};
|
|
162
161
|
const warnedAliases = new Set();
|
|
162
|
+
/** Bundle names are user input: an Object.prototype member ("constructor",
|
|
163
|
+
* "toString") passes a plain `in` or index lookup, so membership is own-key only. */
|
|
164
|
+
export function isKnownBundle(name) {
|
|
165
|
+
return Object.hasOwn(BUNDLE_CATALOG, name);
|
|
166
|
+
}
|
|
167
|
+
const PERSONAL_ADDRESS_RE = /@(?:gmail|googlemail)\.com$/i;
|
|
168
|
+
/** A gmail.com or googlemail.com address, which is always a personal Google account. */
|
|
169
|
+
export function isPersonalGoogleAddress(email) {
|
|
170
|
+
return PERSONAL_ADDRESS_RE.test(email.trim());
|
|
171
|
+
}
|
|
172
|
+
/** The bundles in `bundles` that Google refuses on the consent screen of the account at `email`. */
|
|
173
|
+
export function refusedBundlesFor(email, bundles) {
|
|
174
|
+
if (!isPersonalGoogleAddress(email))
|
|
175
|
+
return [];
|
|
176
|
+
return bundles.filter((b) => isKnownBundle(b) && BUNDLE_CATALOG[b].personalRefused === true);
|
|
177
|
+
}
|
|
163
178
|
export function resolveBundleAliases(bundles) {
|
|
164
179
|
return bundles.map((name) => {
|
|
165
|
-
const target = BUNDLE_ALIASES[name];
|
|
180
|
+
const target = Object.hasOwn(BUNDLE_ALIASES, name) ? BUNDLE_ALIASES[name] : undefined;
|
|
166
181
|
if (!target)
|
|
167
182
|
return name;
|
|
168
183
|
if (!warnedAliases.has(name)) {
|
|
@@ -177,6 +192,8 @@ export function closestBundle(name) {
|
|
|
177
192
|
let best;
|
|
178
193
|
let bestDist = 3;
|
|
179
194
|
for (const key of Object.keys(BUNDLE_CATALOG)) {
|
|
195
|
+
if (Math.abs(name.length - key.length) >= bestDist)
|
|
196
|
+
continue;
|
|
180
197
|
const d = editDistance(name.toLowerCase(), key);
|
|
181
198
|
if (d < bestDist) {
|
|
182
199
|
bestDist = d;
|
|
@@ -185,14 +202,24 @@ export function closestBundle(name) {
|
|
|
185
202
|
}
|
|
186
203
|
return best;
|
|
187
204
|
}
|
|
205
|
+
/** Longest input any did-you-mean compares. Every name it suggests (tool,
|
|
206
|
+
* argument key, bundle, Discovery method id) is far shorter, and the inputs
|
|
207
|
+
* are caller-controlled: an unbounded comparison let one oversized name block
|
|
208
|
+
* the event loop for minutes. */
|
|
209
|
+
export const SUGGEST_MAX_INPUT = 128;
|
|
210
|
+
/** Levenshtein distance. Past SUGGEST_MAX_INPUT it returns the longer length
|
|
211
|
+
* without comparing: an upper bound on the true distance, and above every
|
|
212
|
+
* caller's accept threshold, so a capped pair is never suggested. */
|
|
188
213
|
export function editDistance(a, b) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
214
|
+
if (a.length > SUGGEST_MAX_INPUT || b.length > SUGGEST_MAX_INPUT)
|
|
215
|
+
return Math.max(a.length, b.length);
|
|
216
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
192
217
|
for (let i = 1; i <= a.length; i++) {
|
|
218
|
+
const row = [i];
|
|
193
219
|
for (let j = 1; j <= b.length; j++) {
|
|
194
|
-
|
|
220
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
195
221
|
}
|
|
222
|
+
prev = row;
|
|
196
223
|
}
|
|
197
|
-
return
|
|
224
|
+
return prev[b.length];
|
|
198
225
|
}
|
|
@@ -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;
|
|
@@ -63,7 +65,7 @@ export interface ScopesReport {
|
|
|
63
65
|
bundle: string;
|
|
64
66
|
}[];
|
|
65
67
|
/** Runtime-signal bucket: static classification cannot prove account
|
|
66
|
-
* type; doctor
|
|
68
|
+
* type; doctor fills it from observed persistent admin 403s. */
|
|
67
69
|
accountType: string[];
|
|
68
70
|
unknown: string[];
|
|
69
71
|
};
|
|
@@ -71,8 +73,7 @@ export interface ScopesReport {
|
|
|
71
73
|
}
|
|
72
74
|
/**
|
|
73
75
|
* The account_list / doctor block. Universe = profile ∪ granted ∪ the scopes
|
|
74
|
-
* required by REGISTERED tools ("registered but not requestable"
|
|
75
|
-
* original #114 ask).
|
|
76
|
+
* required by REGISTERED tools ("registered but not requestable" included).
|
|
76
77
|
*/
|
|
77
78
|
export declare function buildScopesReport(granted: ReadonlySet<string>, profile: ReadonlySet<string>, registeredScopes?: Iterable<string>): ScopesReport;
|
|
78
79
|
export declare function clearScopeIndexForTest(): void;
|
|
@@ -1,9 +1,10 @@
|
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
//
|
|
4
|
+
import { reauthHint } from './reauth-hint.js';
|
|
5
|
+
import { hints } from './hint-provider.js';
|
|
6
|
+
// Reverse index scope -> bundles offering it, narrowest first, so the
|
|
7
|
+
// remediation names the smallest grant.
|
|
7
8
|
let reverseIndex = null;
|
|
8
9
|
function scopeIndex() {
|
|
9
10
|
if (reverseIndex)
|
|
@@ -71,40 +72,51 @@ export function classifyMethodScopes(alternatives, granted, profile) {
|
|
|
71
72
|
*/
|
|
72
73
|
export function scopeHint(scope, c, alias) {
|
|
73
74
|
switch (c.state) {
|
|
74
|
-
case 'requestable_not_granted':
|
|
75
|
+
case 'requestable_not_granted': {
|
|
76
|
+
const rendered = hints()?.render({ kind: 'scope_not_granted', account: alias, scope });
|
|
75
77
|
return {
|
|
76
|
-
hint:
|
|
77
|
-
`
|
|
78
|
-
|
|
78
|
+
hint: rendered?.hint ??
|
|
79
|
+
(`Scope ${scope} is in ${alias}'s profile but not granted (you unchecked it at consent). ` +
|
|
80
|
+
reauthHint(alias)),
|
|
81
|
+
// The ONE kind whose retriable the host may flip, since the fix needs the user in a browser.
|
|
82
|
+
retriable: rendered?.retriable ?? true,
|
|
79
83
|
};
|
|
84
|
+
}
|
|
80
85
|
case 'not_requestable':
|
|
81
86
|
if (c.reason === 'add_bundle') {
|
|
82
|
-
// "base" is a pseudo-bundle
|
|
83
|
-
//
|
|
84
|
-
// would fail boot with E_UNKNOWN_BUNDLE.
|
|
87
|
+
// "base" is a pseudo-bundle, not a catalog key: telling users to add
|
|
88
|
+
// it to bundles[] would fail boot with E_UNKNOWN_BUNDLE.
|
|
85
89
|
if (c.bundle === 'base') {
|
|
90
|
+
const rendered = hints()?.render({ kind: 'scope_base_excluded', account: alias, scope });
|
|
86
91
|
return {
|
|
87
|
-
hint:
|
|
88
|
-
`
|
|
92
|
+
hint: rendered?.hint ??
|
|
93
|
+
(`Scope ${scope} is a base scope excluded by "includesBase": false in ${alias}'s profile. ` +
|
|
94
|
+
`Set includesBase back to true (or remove it) in config.json, then npx mcp-google-multi auth --account ${alias}`),
|
|
89
95
|
retriable: false,
|
|
90
96
|
};
|
|
91
97
|
}
|
|
98
|
+
const rendered = hints()?.render({ kind: 'scope_needs_bundle', account: alias, scope, bundle: c.bundle });
|
|
92
99
|
return {
|
|
93
|
-
hint:
|
|
94
|
-
`
|
|
100
|
+
hint: rendered?.hint ??
|
|
101
|
+
(`Scope ${scope} needs the "${c.bundle}" bundle, which is not in ${alias}'s profile. ` +
|
|
102
|
+
`Add it: set ${alias}'s profile bundles to include "${c.bundle}" in config.json, then npx mcp-google-multi auth --account ${alias}`),
|
|
95
103
|
retriable: false,
|
|
96
104
|
};
|
|
97
105
|
}
|
|
98
106
|
if (c.reason === 'account_type') {
|
|
107
|
+
const rendered = hints()?.render({ kind: 'scope_account_type', account: alias, scope });
|
|
99
108
|
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.`,
|
|
109
|
+
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.`,
|
|
110
|
+
retriable: false,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
{
|
|
114
|
+
const rendered = hints()?.render({ kind: 'scope_unknown_scope', scope });
|
|
115
|
+
return {
|
|
116
|
+
hint: rendered?.hint ?? `Scope ${scope} is not offered by this server (no bundle grants it). Not requestable. Do not retry.`,
|
|
101
117
|
retriable: false,
|
|
102
118
|
};
|
|
103
119
|
}
|
|
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
120
|
default:
|
|
109
121
|
return { hint: '', retriable: true };
|
|
110
122
|
}
|
|
@@ -137,9 +149,8 @@ export function scopeHintForMethod(required, alias, deps = {}) {
|
|
|
137
149
|
if (!best || best.state === 'callable')
|
|
138
150
|
return null; // 403 despite grants: not a scope-classification problem
|
|
139
151
|
const base = scopeHint(best.scope, best.classification, alias);
|
|
140
|
-
// Hedged, never fabricated:
|
|
141
|
-
//
|
|
142
|
-
// granted admin — the inputs cannot distinguish, so say both.
|
|
152
|
+
// Hedged, never fabricated: the inputs can't distinguish a Workspace account
|
|
153
|
+
// needing re-auth from a personal account that can never grant admin, so say both.
|
|
143
154
|
if (best.state === 'requestable_not_granted' && ADMIN_SCOPES.includes(best.scope)) {
|
|
144
155
|
return {
|
|
145
156
|
hint: `${base.hint} Note: personal (non-Workspace) accounts can never grant admin scopes — if ${alias} is a personal Gmail account, use a Workspace alias instead.`,
|
|
@@ -150,8 +161,7 @@ export function scopeHintForMethod(required, alias, deps = {}) {
|
|
|
150
161
|
}
|
|
151
162
|
/**
|
|
152
163
|
* The account_list / doctor block. Universe = profile ∪ granted ∪ the scopes
|
|
153
|
-
* required by REGISTERED tools ("registered but not requestable"
|
|
154
|
-
* original #114 ask).
|
|
164
|
+
* required by REGISTERED tools ("registered but not requestable" included).
|
|
155
165
|
*/
|
|
156
166
|
export function buildScopesReport(granted, profile, registeredScopes = []) {
|
|
157
167
|
const callable = [];
|