mcp-google-multi 6.0.0-beta.3 → 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 +3 -3
- package/dist/accounts.js +18 -31
- package/dist/arg-normalize.js +15 -35
- package/dist/arg-strict.js +8 -28
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +5 -10
- package/dist/boot-gates.d.ts +1 -1
- package/dist/boot-gates.js +3 -5
- package/dist/cimd-cache.js +5 -11
- package/dist/client-config.d.ts +2 -2
- package/dist/client-config.js +8 -14
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +8 -1
- package/dist/compose.js +11 -11
- package/dist/config-file.js +7 -12
- package/dist/discover.js +4 -9
- package/dist/discovery-client.js +14 -33
- package/dist/doctor.d.ts +3 -3
- package/dist/doctor.js +12 -18
- package/dist/env-load.js +2 -5
- package/dist/executor.js +2 -4
- package/dist/fanout.js +8 -17
- package/dist/fs-atomic.d.ts +0 -3
- package/dist/fs-atomic.js +6 -10
- package/dist/hint-provider.js +4 -10
- package/dist/http-config.d.ts +2 -2
- package/dist/http-config.js +3 -7
- package/dist/http-transport.d.ts +5 -5
- package/dist/http-transport.js +39 -92
- package/dist/identity.d.ts +2 -2
- package/dist/identity.js +6 -9
- package/dist/index.js +19 -31
- package/dist/master-key.d.ts +3 -3
- package/dist/master-key.js +11 -22
- package/dist/mcp-token.d.ts +3 -13
- package/dist/mcp-token.js +16 -32
- 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 +3 -5
- package/dist/net-tuning.js +2 -4
- package/dist/oauth-as.d.ts +2 -2
- package/dist/oauth-as.js +32 -73
- package/dist/oauth-consent.js +4 -10
- package/dist/outbound-allowlist.js +4 -9
- package/dist/reauth-hint.js +2 -5
- package/dist/registry-transfer.d.ts +1 -1
- package/dist/registry-transfer.js +5 -13
- package/dist/registry.d.ts +1 -9
- package/dist/registry.js +38 -66
- package/dist/scope-catalog.d.ts +5 -0
- package/dist/scope-catalog.js +17 -7
- package/dist/scope-observability.d.ts +2 -3
- package/dist/scope-observability.js +8 -14
- package/dist/services.d.ts +3 -0
- package/dist/services.js +5 -10
- package/dist/ssrf-guard.js +11 -22
- package/dist/tenant-purge.js +2 -4
- package/dist/token-store.js +0 -0
- package/dist/tools/_coerce.d.ts +4 -4
- package/dist/tools/_errors.js +22 -39
- package/dist/tools/_local-files.js +4 -8
- package/dist/tools/account-wizard.d.ts +4 -3
- package/dist/tools/account-wizard.js +48 -54
- package/dist/tools/accounts-tool.d.ts +1 -1
- package/dist/tools/accounts-tool.js +1 -1
- package/dist/tools/admin.js +2 -3
- package/dist/tools/analytics.js +4 -5
- package/dist/tools/calendar.js +4 -7
- package/dist/tools/chat.js +2 -3
- package/dist/tools/contacts.js +8 -9
- package/dist/tools/docs.js +4 -9
- package/dist/tools/drive.js +18 -27
- package/dist/tools/forms.js +2 -3
- package/dist/tools/gmail-mime.d.ts +1 -1
- package/dist/tools/gmail-mime.js +7 -12
- package/dist/tools/gmail.d.ts +2 -2
- package/dist/tools/gmail.js +26 -40
- package/dist/tools/google-api.js +5 -7
- package/dist/tools/meet.js +2 -3
- package/dist/tools/searchconsole.js +2 -3
- package/dist/tools/sheets.js +2 -3
- package/dist/tools/slides.js +2 -3
- package/dist/tools/tasks.js +2 -3
- package/dist/trim.js +2 -3
- package/dist/usage-metrics.js +5 -12
- package/dist/write-control.d.ts +4 -8
- package/dist/write-control.js +3 -4
- package/package.json +21 -21
package/dist/registry.js
CHANGED
|
@@ -10,9 +10,8 @@ import { hints } from './hint-provider.js';
|
|
|
10
10
|
/** Nothing granted, no profile: asks the BUNDLE CATALOG whether a scope is
|
|
11
11
|
* reachable at all, independently of any account. */
|
|
12
12
|
const EMPTY_SCOPES = new Set();
|
|
13
|
-
// Client-side result budget
|
|
14
|
-
//
|
|
15
|
-
// 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.
|
|
16
15
|
const DEFAULT_MAX_RESULT_CHARS = 50_000;
|
|
17
16
|
const DISCOVERY_MODES = ['lazy', 'curated', 'eager'];
|
|
18
17
|
export function resolveDiscoveryMode(env = process.env) {
|
|
@@ -29,9 +28,8 @@ const CUD_OVERRIDES = {
|
|
|
29
28
|
drive_transfer: 'create',
|
|
30
29
|
// read-only resolver; the name's "resolve" verb would otherwise infer update.
|
|
31
30
|
contacts_resolve: 'read',
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// 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.
|
|
35
33
|
account_add: 'read',
|
|
36
34
|
account_reauth: 'read',
|
|
37
35
|
// Writes a LOCAL MCP-client config, not Google data; gated by
|
|
@@ -47,10 +45,11 @@ export function serviceOf(name) {
|
|
|
47
45
|
}
|
|
48
46
|
// read tools that write local files — same savePath fanned across accounts would clobber
|
|
49
47
|
const FANOUT_EXCLUDE = new Set(['gmail_download_attachment', 'drive_download', 'drive_export']);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
54
53
|
const DEFAULT_ACCOUNT_EXCLUDE = new Set(['account_add', 'account_reauth']);
|
|
55
54
|
/** Unwrap optional/default/nullable to the declared scalar kind (zod 4 defs). */
|
|
56
55
|
function scalarKindOf(field) {
|
|
@@ -77,7 +76,7 @@ function isAccountSelector(field) {
|
|
|
77
76
|
const def = field?._zod?.def;
|
|
78
77
|
if (!def)
|
|
79
78
|
return false;
|
|
80
|
-
//
|
|
79
|
+
// Account enums are .optional(); unwrap it or fan-out silently dies.
|
|
81
80
|
if (def.type === 'optional')
|
|
82
81
|
return def.innerType?._zod?.def?.type === 'enum';
|
|
83
82
|
return def.type === 'enum';
|
|
@@ -107,8 +106,7 @@ export class ToolRegistry {
|
|
|
107
106
|
revealed = new Set();
|
|
108
107
|
jsonSchemaCache = new Map();
|
|
109
108
|
argShapeCache = new Map();
|
|
110
|
-
|
|
111
|
-
* bundle catalog only, so it never changes within a process. */
|
|
109
|
+
// Memoized isUngrantable verdict: depends on the frozen bundle catalog only.
|
|
112
110
|
ungrantable = new Map();
|
|
113
111
|
compactOutput = trimEnabled();
|
|
114
112
|
registeringMeta = false;
|
|
@@ -118,9 +116,8 @@ export class ToolRegistry {
|
|
|
118
116
|
* lazy surface to curated without touching the configured mode. */
|
|
119
117
|
expanded = false;
|
|
120
118
|
constructor(server, policy, mode = resolveDiscoveryMode(), metrics = null,
|
|
121
|
-
// The registry's OWN account view
|
|
122
|
-
//
|
|
123
|
-
// over a subset never observes (or leaks) the global set.
|
|
119
|
+
// The registry's OWN account view, so one built over a subset never
|
|
120
|
+
// observes (or leaks) the global set.
|
|
124
121
|
accounts = getAccountSet) {
|
|
125
122
|
this.server = server;
|
|
126
123
|
this.metrics = metrics;
|
|
@@ -130,22 +127,17 @@ export class ToolRegistry {
|
|
|
130
127
|
this.registerTool = ((name, config, handler) => {
|
|
131
128
|
const service = serviceOf(name);
|
|
132
129
|
const cud = config.cud ?? inferCud(name);
|
|
133
|
-
//
|
|
134
|
-
// idempotent
|
|
135
|
-
|
|
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);
|
|
136
133
|
const annotations = {
|
|
137
|
-
readOnlyHint:
|
|
138
|
-
destructiveHint:
|
|
134
|
+
readOnlyHint: !writes,
|
|
135
|
+
destructiveHint: writes,
|
|
139
136
|
idempotentHint: cud !== 'create',
|
|
140
137
|
...config.annotations,
|
|
141
138
|
};
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// anthropic/* ONLY there); the server verdict stays separate.
|
|
145
|
-
// Every tool also advertises a result-size budget: fat readers declare
|
|
146
|
-
// their own, everything else inherits the default, so an uncapped tool
|
|
147
|
-
// can never blow past a client's context limit. Generated tools align
|
|
148
|
-
// 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.
|
|
149
141
|
const clientMeta = {
|
|
150
142
|
'anthropic/maxResultSizeChars': config.cud !== undefined ? MAX_RESPONSE_CHARS : DEFAULT_MAX_RESULT_CHARS,
|
|
151
143
|
...config._meta,
|
|
@@ -190,10 +182,8 @@ export class ToolRegistry {
|
|
|
190
182
|
: writeDisabledResult({ name, service, cud, scopes: config.requiredScopes }, policy,
|
|
191
183
|
// Concrete by here: default-account injection wraps this.
|
|
192
184
|
args[0]?.account);
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
// (that is what covers google_api_call with zero bespoke code). Explicit
|
|
196
|
-
// 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).
|
|
197
187
|
const withDefault = !hasAccountField
|
|
198
188
|
? guarded
|
|
199
189
|
: (...args) => {
|
|
@@ -201,9 +191,8 @@ export class ToolRegistry {
|
|
|
201
191
|
const value = first?.account;
|
|
202
192
|
if (value != null && value !== '')
|
|
203
193
|
return guarded(...args);
|
|
204
|
-
// Refresh here or
|
|
205
|
-
//
|
|
206
|
-
// 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).
|
|
207
196
|
refreshAccountSetIfStale();
|
|
208
197
|
const def = this.accounts().defaultAccount;
|
|
209
198
|
if (!def) {
|
|
@@ -229,10 +218,8 @@ export class ToolRegistry {
|
|
|
229
218
|
const finalHandler = this.compactOutput
|
|
230
219
|
? async (...args) => compactResult(await withDefault(...args))
|
|
231
220
|
: withDefault;
|
|
232
|
-
// Usage metrics wrap
|
|
233
|
-
//
|
|
234
|
-
// chain. Fan-out width is derived here (bucketed in the module) so the
|
|
235
|
-
// 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.
|
|
236
223
|
const instrumented = this.metrics
|
|
237
224
|
? this.metrics.wrap({ name, service, meta: this.registeringMeta, generated: config.cud !== undefined }, finalHandler, (a) => {
|
|
238
225
|
const v = a?.account;
|
|
@@ -301,10 +288,8 @@ export class ToolRegistry {
|
|
|
301
288
|
const declared = new Set(Object.keys(self.inputShape));
|
|
302
289
|
const byKey = new Map();
|
|
303
290
|
for (const t of this.tools) {
|
|
304
|
-
// Only tools
|
|
305
|
-
//
|
|
306
|
-
// into a dead end: in curated mode 353 tools are registered and 181 are
|
|
307
|
-
// 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.
|
|
308
293
|
if (t.service !== self.service || t.name === tool || !this.isVisible(t))
|
|
309
294
|
continue;
|
|
310
295
|
for (const key of Object.keys(t.inputShape)) {
|
|
@@ -318,9 +303,8 @@ export class ToolRegistry {
|
|
|
318
303
|
}
|
|
319
304
|
}
|
|
320
305
|
const candidates = [...byKey.entries()].filter(([, e]) => e.tools.length >= 2 || e.curated);
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
// 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.
|
|
324
308
|
const keys = candidates.map(([key]) => key);
|
|
325
309
|
const hits = new Set(unknownKeys.slice(0, MAX_SUGGESTED_KEYS).flatMap((k) => suggestKeys(k, keys)));
|
|
326
310
|
return candidates
|
|
@@ -386,7 +370,7 @@ export class ToolRegistry {
|
|
|
386
370
|
return true;
|
|
387
371
|
}
|
|
388
372
|
/** discover_reset: back to the lean meta-only surface. Clears reveals too.
|
|
389
|
-
*
|
|
373
|
+
* If a client mishandles a SHRINKING tools/list, this degrades to a
|
|
390
374
|
* no-op for that session — tools stay callable regardless (graceful
|
|
391
375
|
* dispatch), zero correctness impact. */
|
|
392
376
|
collapse() {
|
|
@@ -416,11 +400,8 @@ export class ToolRegistry {
|
|
|
416
400
|
const cached = this.ungrantable.get(tool.name);
|
|
417
401
|
if (cached !== undefined)
|
|
418
402
|
return cached;
|
|
419
|
-
// EVERY alternative must be ungrantable
|
|
420
|
-
//
|
|
421
|
-
// `not_requestable` rank, so its first-best match hides a grantable
|
|
422
|
-
// alternative that appears later in the list. Discovery scope lists are
|
|
423
|
-
// 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.
|
|
424
405
|
const verdict = tool.requiredScopes.every((scope) => {
|
|
425
406
|
const c = classifyScope(scope, EMPTY_SCOPES, EMPTY_SCOPES);
|
|
426
407
|
return c.state === 'not_requestable' && c.reason === 'unknown_scope';
|
|
@@ -430,10 +411,8 @@ export class ToolRegistry {
|
|
|
430
411
|
}
|
|
431
412
|
isVisible(tool) {
|
|
432
413
|
const mode = this.effectiveMode();
|
|
433
|
-
//
|
|
434
|
-
// the
|
|
435
|
-
// name still produces the real scope error plus its remediation, and
|
|
436
|
-
// {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.
|
|
437
416
|
if (!tool.meta && this.isUngrantable(tool))
|
|
438
417
|
return false;
|
|
439
418
|
return (tool.meta ||
|
|
@@ -453,10 +432,7 @@ export class ToolRegistry {
|
|
|
453
432
|
throw new Error('installListHandler() requires at least one registered tool');
|
|
454
433
|
}
|
|
455
434
|
this.server.server.setRequestHandler('tools/list', async () => ({
|
|
456
|
-
//
|
|
457
|
-
// anthropic/* _meta keys honoring clients read. z.toJSONSchema emits a
|
|
458
|
-
// valid draft-7 object schema by construction, which the SDK's recursive
|
|
459
|
-
// 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.
|
|
460
436
|
tools: this.tools.filter((t) => this.isVisible(t)).map((t) => this.toToolJson(t)),
|
|
461
437
|
}));
|
|
462
438
|
}
|
|
@@ -474,12 +450,8 @@ export class ToolRegistry {
|
|
|
474
450
|
...(tool.clientMeta ? { _meta: tool.clientMeta } : {}),
|
|
475
451
|
};
|
|
476
452
|
}
|
|
477
|
-
|
|
478
|
-
|
|
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). */
|
|
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.
|
|
483
455
|
withLiveAccountEnum(tool, schema) {
|
|
484
456
|
if (DEFAULT_ACCOUNT_EXCLUDE.has(tool.name))
|
|
485
457
|
return schema;
|
package/dist/scope-catalog.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ 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>;
|
|
@@ -17,6 +18,10 @@ export declare const BUNDLE_ALIASES: Record<string, string>;
|
|
|
17
18
|
/** Bundle names are user input: an Object.prototype member ("constructor",
|
|
18
19
|
* "toString") passes a plain `in` or index lookup, so membership is own-key only. */
|
|
19
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[];
|
|
20
25
|
export declare function resolveBundleAliases(bundles: string[]): string[];
|
|
21
26
|
/** Closest catalog key for E_UNKNOWN_BUNDLE remediation (edit distance <= 2). */
|
|
22
27
|
export declare function closestBundle(name: string): string | undefined;
|
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',
|
|
@@ -165,6 +164,17 @@ const warnedAliases = new Set();
|
|
|
165
164
|
export function isKnownBundle(name) {
|
|
166
165
|
return Object.hasOwn(BUNDLE_CATALOG, name);
|
|
167
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
|
+
}
|
|
168
178
|
export function resolveBundleAliases(bundles) {
|
|
169
179
|
return bundles.map((name) => {
|
|
170
180
|
const target = Object.hasOwn(BUNDLE_ALIASES, name) ? BUNDLE_ALIASES[name] : undefined;
|
|
@@ -65,7 +65,7 @@ export interface ScopesReport {
|
|
|
65
65
|
bundle: string;
|
|
66
66
|
}[];
|
|
67
67
|
/** Runtime-signal bucket: static classification cannot prove account
|
|
68
|
-
* type; doctor
|
|
68
|
+
* type; doctor fills it from observed persistent admin 403s. */
|
|
69
69
|
accountType: string[];
|
|
70
70
|
unknown: string[];
|
|
71
71
|
};
|
|
@@ -73,8 +73,7 @@ export interface ScopesReport {
|
|
|
73
73
|
}
|
|
74
74
|
/**
|
|
75
75
|
* The account_list / doctor block. Universe = profile ∪ granted ∪ the scopes
|
|
76
|
-
* required by REGISTERED tools ("registered but not requestable"
|
|
77
|
-
* original #114 ask).
|
|
76
|
+
* required by REGISTERED tools ("registered but not requestable" included).
|
|
78
77
|
*/
|
|
79
78
|
export declare function buildScopesReport(granted: ReadonlySet<string>, profile: ReadonlySet<string>, registeredScopes?: Iterable<string>): ScopesReport;
|
|
80
79
|
export declare function clearScopeIndexForTest(): void;
|
|
@@ -3,9 +3,8 @@ import { BASE_SCOPES, resolveScopesForAccount } from './auth.js';
|
|
|
3
3
|
import { readToken } from './token-store.js';
|
|
4
4
|
import { reauthHint } from './reauth-hint.js';
|
|
5
5
|
import { hints } from './hint-provider.js';
|
|
6
|
-
// Reverse index scope -> bundles offering it, narrowest
|
|
7
|
-
//
|
|
8
|
-
// pseudo-bundle "base" (outside a profile only via includesBase:false).
|
|
6
|
+
// Reverse index scope -> bundles offering it, narrowest first, so the
|
|
7
|
+
// remediation names the smallest grant.
|
|
9
8
|
let reverseIndex = null;
|
|
10
9
|
function scopeIndex() {
|
|
11
10
|
if (reverseIndex)
|
|
@@ -79,17 +78,14 @@ export function scopeHint(scope, c, alias) {
|
|
|
79
78
|
hint: rendered?.hint ??
|
|
80
79
|
(`Scope ${scope} is in ${alias}'s profile but not granted (you unchecked it at consent). ` +
|
|
81
80
|
reauthHint(alias)),
|
|
82
|
-
// The ONE kind whose retriable the host may flip
|
|
83
|
-
// owner keeps true, a multi-tenant host may report false because the
|
|
84
|
-
// fix needs the user in a browser.
|
|
81
|
+
// The ONE kind whose retriable the host may flip, since the fix needs the user in a browser.
|
|
85
82
|
retriable: rendered?.retriable ?? true,
|
|
86
83
|
};
|
|
87
84
|
}
|
|
88
85
|
case 'not_requestable':
|
|
89
86
|
if (c.reason === 'add_bundle') {
|
|
90
|
-
// "base" is a pseudo-bundle
|
|
91
|
-
//
|
|
92
|
-
// 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.
|
|
93
89
|
if (c.bundle === 'base') {
|
|
94
90
|
const rendered = hints()?.render({ kind: 'scope_base_excluded', account: alias, scope });
|
|
95
91
|
return {
|
|
@@ -153,9 +149,8 @@ export function scopeHintForMethod(required, alias, deps = {}) {
|
|
|
153
149
|
if (!best || best.state === 'callable')
|
|
154
150
|
return null; // 403 despite grants: not a scope-classification problem
|
|
155
151
|
const base = scopeHint(best.scope, best.classification, alias);
|
|
156
|
-
// Hedged, never fabricated:
|
|
157
|
-
//
|
|
158
|
-
// 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.
|
|
159
154
|
if (best.state === 'requestable_not_granted' && ADMIN_SCOPES.includes(best.scope)) {
|
|
160
155
|
return {
|
|
161
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.`,
|
|
@@ -166,8 +161,7 @@ export function scopeHintForMethod(required, alias, deps = {}) {
|
|
|
166
161
|
}
|
|
167
162
|
/**
|
|
168
163
|
* The account_list / doctor block. Universe = profile ∪ granted ∪ the scopes
|
|
169
|
-
* required by REGISTERED tools ("registered but not requestable"
|
|
170
|
-
* original #114 ask).
|
|
164
|
+
* required by REGISTERED tools ("registered but not requestable" included).
|
|
171
165
|
*/
|
|
172
166
|
export function buildScopesReport(granted, profile, registeredScopes = []) {
|
|
173
167
|
const callable = [];
|
package/dist/services.d.ts
CHANGED
|
@@ -14,6 +14,9 @@ export declare const GENERATED_GATES: Record<string, {
|
|
|
14
14
|
enabled: (set?: AccountSet) => boolean;
|
|
15
15
|
hint: string;
|
|
16
16
|
}>;
|
|
17
|
+
/** Message for a `tools/call` naming a tool that is not registered: the SDK's
|
|
18
|
+
* bare "Tool X not found" reads identically for a typo, a gated service, and
|
|
19
|
+
* an unknown API, but the server knows which is which from its boot-time enabled-service list. */
|
|
17
20
|
export declare function unknownToolMessage(registry: ToolRegistry, rawName: string): string;
|
|
18
21
|
/**
|
|
19
22
|
* Gated services the registry's CURRENT accounts enable but that registered
|
package/dist/services.js
CHANGED
|
@@ -57,20 +57,15 @@ export const GENERATED_GATES = {
|
|
|
57
57
|
script: bundleGate('script'),
|
|
58
58
|
vault: bundleGate('vault'),
|
|
59
59
|
};
|
|
60
|
-
/**
|
|
61
|
-
* Message for a `tools/call` naming a tool that is not registered. The SDK's
|
|
62
|
-
* own answer is a bare "Tool X not found", which reads identically for a typo,
|
|
63
|
-
* for a service gated behind a scope bundle, and for an API this server has
|
|
64
|
-
* never heard of. Those need different next steps, and the server knows which
|
|
65
|
-
* is which: it composed the enabled-service list at boot.
|
|
66
|
-
*/
|
|
67
60
|
const MAX_TOOL_NAME_ECHO = 64;
|
|
61
|
+
/** Message for a `tools/call` naming a tool that is not registered: the SDK's
|
|
62
|
+
* bare "Tool X not found" reads identically for a typo, a gated service, and
|
|
63
|
+
* an unknown API, but the server knows which is which from its boot-time enabled-service list. */
|
|
68
64
|
export function unknownToolMessage(registry, rawName) {
|
|
69
65
|
// The name is the caller's, unvalidated: echo a bounded prefix only.
|
|
70
66
|
const name = rawName.length > MAX_TOOL_NAME_ECHO ? `${sliceClean(rawName, MAX_TOOL_NAME_ECHO)}...` : rawName;
|
|
71
|
-
// A service that EXISTS
|
|
72
|
-
//
|
|
73
|
-
// chat_spaces_get at meet_spaces_get.
|
|
67
|
+
// A service that EXISTS but registered nothing is off, not misspelled;
|
|
68
|
+
// checked before the did-you-mean, which would otherwise point chat_spaces_get at meet_spaces_get.
|
|
74
69
|
const service = serviceOf(name);
|
|
75
70
|
const known = SERVICES.some((s) => s.name === service) || GENERATED_SERVICES.some((s) => s.name === service);
|
|
76
71
|
if (known && !registry.services().includes(service)) {
|
package/dist/ssrf-guard.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// no dependency — HTTPS-only, resolve-then-check ALL addresses (anti-rebind),
|
|
4
|
-
// no redirects followed, and capped size and time. Blocks 10/8, 172.16/12,
|
|
5
|
-
// 192.168/16, 127/8, 169.254/16, 0/8, multicast, ::1, fc00::/7, fe80::/10, and
|
|
6
|
-
// v4-mapped forms.
|
|
1
|
+
// SSRF guard for every server-side fetch (the CIMD client_id doc): HTTPS-only,
|
|
2
|
+
// resolve-then-check ALL addresses (anti-rebind), no redirects, capped size/time.
|
|
7
3
|
import { lookup } from 'node:dns/promises';
|
|
8
4
|
import { isIPv4, isIPv6, BlockList } from 'node:net';
|
|
9
5
|
export class SsrfBlockedError extends Error {
|
|
@@ -46,10 +42,8 @@ export function isBlockedIPv4(ip) {
|
|
|
46
42
|
return true; // 224/4 multicast + 240/4 reserved
|
|
47
43
|
return false;
|
|
48
44
|
}
|
|
49
|
-
// Built-in subnet math
|
|
50
|
-
//
|
|
51
|
-
// NAT64 / 6to4 embeddings (a public host never resolves to those, so blocking
|
|
52
|
-
// the whole range closes the embedded-private-target gap without parsing).
|
|
45
|
+
// Built-in subnet math blocks v4-mapped/NAT64/6to4 embeddings too: a public
|
|
46
|
+
// host never resolves to those, so the whole range closes the embedding gap.
|
|
53
47
|
const V6_BLOCKED = new BlockList();
|
|
54
48
|
V6_BLOCKED.addSubnet('::', 96, 'ipv6'); // ::/96 = ::1, ::, and v4-compatible
|
|
55
49
|
V6_BLOCKED.addSubnet('::ffff:0:0', 96, 'ipv6'); // v4-mapped
|
|
@@ -145,10 +139,8 @@ export async function assertPublicHttpsUrl(rawUrl, deps = {}) {
|
|
|
145
139
|
}
|
|
146
140
|
return { url, addresses };
|
|
147
141
|
}
|
|
148
|
-
// Connection-level failures
|
|
149
|
-
//
|
|
150
|
-
// DNS blip are transient; an SSRF block, a bad HTTP status, or malformed JSON
|
|
151
|
-
// are deterministic and must NOT be retried.
|
|
142
|
+
// Connection-level failures a retry can recover from; an SSRF block, a bad
|
|
143
|
+
// HTTP status, or malformed JSON are deterministic and must NOT be retried.
|
|
152
144
|
const TRANSIENT_CODES = new Set([
|
|
153
145
|
'ETIMEDOUT',
|
|
154
146
|
'ECONNRESET',
|
|
@@ -170,8 +162,7 @@ export function isTransientFetchError(e) {
|
|
|
170
162
|
const code = cause?.code ?? e?.code;
|
|
171
163
|
if (code && TRANSIENT_CODES.has(code))
|
|
172
164
|
return true;
|
|
173
|
-
// undici
|
|
174
|
-
// real reason in .cause; retry even when the cause carries no useful code.
|
|
165
|
+
// undici's TypeError('fetch failed') retries even with no useful .cause code.
|
|
175
166
|
if (e instanceof TypeError && /fetch failed/i.test(e.message))
|
|
176
167
|
return true;
|
|
177
168
|
return false;
|
|
@@ -197,9 +188,8 @@ export async function fetchCimdDocument(rawUrl, opts = {}) {
|
|
|
197
188
|
const backoffMs = opts.retryBackoffMs ?? 200;
|
|
198
189
|
const doFetch = opts.fetchImpl ?? fetch;
|
|
199
190
|
const sleep = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
// the timer is per-attempt (armed across the lookup and the body read, #10).
|
|
191
|
+
// Each attempt re-runs resolve-then-check so a retry can't skip the
|
|
192
|
+
// anti-rebind guard; the timer is per-attempt, armed across lookup and body read.
|
|
203
193
|
for (let attempt = 0;; attempt++) {
|
|
204
194
|
const controller = new AbortController();
|
|
205
195
|
let timedOut = false;
|
|
@@ -247,9 +237,8 @@ export async function fetchCimdDocument(rawUrl, opts = {}) {
|
|
|
247
237
|
}
|
|
248
238
|
}
|
|
249
239
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
* readable stream (e.g. a test Response). */
|
|
240
|
+
// Aborts the moment maxBytes is exceeded (never buffers an oversized body);
|
|
241
|
+
// falls back to a capped arrayBuffer when there's no readable stream (e.g. a test Response).
|
|
253
242
|
async function readCapped(res, maxBytes, controller) {
|
|
254
243
|
const reader = res.body?.getReader?.();
|
|
255
244
|
if (!reader) {
|
package/dist/tenant-purge.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// ORCHESTRATION (what to purge, in what order, from which admin surface)
|
|
4
|
-
// lives with the tenancy module that calls these.
|
|
1
|
+
// Tenant offboarding primitives: core owns the reusable token-purge legs;
|
|
2
|
+
// orchestration (what to purge, in what order) lives with the tenancy module calling these.
|
|
5
3
|
import fs from 'node:fs';
|
|
6
4
|
import path from 'node:path';
|
|
7
5
|
import { tenantConfigFilePath, tenantTokenDir } from './config-file.js';
|
package/dist/token-store.js
CHANGED
|
Binary file
|
package/dist/tools/_coerce.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
export declare function coerceArray<T extends z.ZodTypeAny>(element: T): z.
|
|
3
|
-
export declare function coerceJson<T extends z.ZodTypeAny>(schema: T): z.
|
|
2
|
+
export declare function coerceArray<T extends z.ZodTypeAny>(element: T): z.ZodPreprocess<z.ZodArray<T>, unknown>;
|
|
3
|
+
export declare function coerceJson<T extends z.ZodTypeAny>(schema: T): z.ZodPreprocess<T, unknown>;
|
|
4
4
|
/** Numeric args arrive string-encoded from some clients. Arg normalization
|
|
5
5
|
* runs on BOTH transports, but it only coerces keys it RENAMED, so a value
|
|
6
6
|
* sent under the correct key never passes through it and the coercion has to
|
|
7
7
|
* live in the schema. */
|
|
8
|
-
export declare function coerceNumber<T extends z.ZodTypeAny>(schema: T): z.
|
|
9
|
-
export declare const coerceBoolean: z.
|
|
8
|
+
export declare function coerceNumber<T extends z.ZodTypeAny>(schema: T): z.ZodPreprocess<T, unknown>;
|
|
9
|
+
export declare const coerceBoolean: z.ZodPreprocess<z.ZodBoolean, unknown>;
|