mcp-google-multi 6.0.0-alpha.71 → 6.0.0-alpha.72
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 +11 -11
- package/dist/arg-strict.js +1 -1
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +3 -3
- package/dist/boot-gates.d.ts +1 -1
- package/dist/boot-gates.js +2 -2
- package/dist/cimd-cache.js +1 -1
- package/dist/client-config.d.ts +2 -2
- package/dist/client-config.js +4 -4
- package/dist/compose.js +1 -1
- package/dist/discover.js +2 -2
- package/dist/doctor.d.ts +2 -2
- package/dist/doctor.js +8 -9
- package/dist/http-config.d.ts +2 -2
- package/dist/http-config.js +4 -5
- package/dist/http-transport.d.ts +5 -5
- package/dist/http-transport.js +7 -7
- package/dist/identity.d.ts +2 -2
- package/dist/identity.js +4 -4
- package/dist/index.js +9 -9
- package/dist/master-key.d.ts +3 -3
- package/dist/master-key.js +5 -5
- package/dist/mcp-token.d.ts +3 -3
- package/dist/mcp-token.js +6 -6
- package/dist/migrate-config.js +1 -1
- package/dist/oauth-as.d.ts +1 -1
- package/dist/oauth-as.js +11 -11
- package/dist/oauth-consent.js +3 -3
- package/dist/outbound-allowlist.js +2 -2
- package/dist/reauth-hint.js +3 -3
- package/dist/registry-transfer.d.ts +1 -1
- package/dist/registry-transfer.js +2 -2
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +4 -4
- package/dist/scope-catalog.d.ts +5 -0
- package/dist/scope-catalog.js +13 -0
- package/dist/scope-observability.d.ts +1 -1
- package/dist/scope-observability.js +1 -1
- package/dist/ssrf-guard.js +3 -4
- package/dist/tenant-purge.js +1 -1
- package/dist/tools/_errors.js +4 -4
- package/dist/tools/account-wizard.d.ts +4 -3
- package/dist/tools/account-wizard.js +27 -12
- package/dist/tools/accounts-tool.d.ts +1 -1
- package/dist/tools/accounts-tool.js +1 -1
- package/dist/tools/admin.js +1 -1
- package/dist/tools/analytics.js +1 -1
- package/dist/tools/calendar.js +1 -1
- package/dist/tools/chat.js +1 -1
- package/dist/tools/contacts.js +4 -4
- package/dist/tools/docs.js +1 -1
- package/dist/tools/drive.js +1 -1
- package/dist/tools/forms.js +1 -1
- package/dist/tools/gmail-mime.d.ts +1 -1
- package/dist/tools/gmail-mime.js +3 -3
- package/dist/tools/gmail.d.ts +2 -2
- package/dist/tools/gmail.js +8 -8
- package/dist/tools/google-api.js +1 -1
- package/dist/tools/meet.js +1 -1
- package/dist/tools/searchconsole.js +1 -1
- package/dist/tools/sheets.js +1 -1
- package/dist/tools/slides.js +1 -1
- package/dist/tools/tasks.js +1 -1
- package/dist/usage-metrics.js +1 -1
- package/dist/write-control.d.ts +4 -5
- package/dist/write-control.js +1 -1
- package/package.json +1 -1
package/dist/master-key.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface KeyDeps {
|
|
|
15
15
|
hasAnyToken?: () => boolean;
|
|
16
16
|
}
|
|
17
17
|
export declare function __setKeychainFactoryForTest(fn: ((account: string) => KeychainEntry | null) | null): void;
|
|
18
|
-
/**
|
|
18
|
+
/** Single choke point: no other module may read the env vars directly.
|
|
19
19
|
* Boot/CLI form: failures exit with a clean stderr slug ("fatal at startup"). */
|
|
20
20
|
export declare function resolveMasterKey(deps?: KeyDeps): ResolvedKey;
|
|
21
21
|
/** Dispatch form: identical resolution, but failures THROW (caught by the tool
|
|
@@ -28,14 +28,14 @@ export declare function peekMasterKeyProvenance(deps?: KeyDeps): KeyProvenance |
|
|
|
28
28
|
* outstanding MCP tokens and clients re-auth silently. */
|
|
29
29
|
export declare function resolveJwtKey(deps?: KeyDeps): ResolvedKey;
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* After the FIRST successful token decrypt with an env-sourced key,
|
|
32
32
|
* mirror it into the keychain (only when the keychain holds no entry) so the
|
|
33
33
|
* key survives an env loss. Gating on a successful decrypt means a stale or
|
|
34
34
|
* wrong env key is never mirrored. Best-effort: failures are doctor material,
|
|
35
35
|
* never fatal.
|
|
36
36
|
*/
|
|
37
37
|
export declare function noteSuccessfulDecrypt(): void;
|
|
38
|
-
/**
|
|
38
|
+
/** Reset: drop the generated MASTER_KEY material (0600 file + keychain
|
|
39
39
|
* entry) so the next run re-provisions. Never removes an env-provided key (the
|
|
40
40
|
* deployer owns that; `env:true` flags it so reset can warn). The caller MUST
|
|
41
41
|
* have wiped every .enc token first — regenerating with tokens present would
|
package/dist/master-key.js
CHANGED
|
@@ -32,7 +32,7 @@ function systemKeychain(account) {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
// Default keychain factory, replaceable ONLY for tests: unit tests must never
|
|
35
|
-
// read or write the developer's real OS keyring (the
|
|
35
|
+
// read or write the developer's real OS keyring (the env->keychain mirror once leaked a
|
|
36
36
|
// test fixture key into a real GNOME keyring — never again).
|
|
37
37
|
let keychainFactory = systemKeychain;
|
|
38
38
|
export function __setKeychainFactoryForTest(fn) {
|
|
@@ -98,7 +98,7 @@ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
|
|
|
98
98
|
const fromFile = readKeyFile(filePath);
|
|
99
99
|
if (fromFile)
|
|
100
100
|
return { key: fromFile, provenance: 'file' };
|
|
101
|
-
// Generate-on-setup. The hard guard
|
|
101
|
+
// Generate-on-setup. The hard guard: minting a fresh
|
|
102
102
|
// MASTER_KEY while <alias>.enc files exist would silently brick them all.
|
|
103
103
|
if (hardGuard && hasAnyToken()) {
|
|
104
104
|
fail('E_MASTER_KEY_MISSING_TOKENS_EXIST', `no ${envName} found in env, keychain or ${filePath}, but encrypted tokens exist under ${getTokenDir()}. ` +
|
|
@@ -151,7 +151,7 @@ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
|
|
|
151
151
|
}
|
|
152
152
|
let cachedMaster = null;
|
|
153
153
|
let cachedJwt = null;
|
|
154
|
-
/**
|
|
154
|
+
/** Single choke point: no other module may read the env vars directly.
|
|
155
155
|
* Boot/CLI form: failures exit with a clean stderr slug ("fatal at startup"). */
|
|
156
156
|
export function resolveMasterKey(deps) {
|
|
157
157
|
if (deps)
|
|
@@ -189,7 +189,7 @@ export function resolveJwtKey(deps) {
|
|
|
189
189
|
}
|
|
190
190
|
let mirrored = false;
|
|
191
191
|
/**
|
|
192
|
-
*
|
|
192
|
+
* After the FIRST successful token decrypt with an env-sourced key,
|
|
193
193
|
* mirror it into the keychain (only when the keychain holds no entry) so the
|
|
194
194
|
* key survives an env loss. Gating on a successful decrypt means a stale or
|
|
195
195
|
* wrong env key is never mirrored. Best-effort: failures are doctor material,
|
|
@@ -211,7 +211,7 @@ export function noteSuccessfulDecrypt() {
|
|
|
211
211
|
// Keychain backend unavailable; env stays the working source.
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
|
-
/**
|
|
214
|
+
/** Reset: drop the generated MASTER_KEY material (0600 file + keychain
|
|
215
215
|
* entry) so the next run re-provisions. Never removes an env-provided key (the
|
|
216
216
|
* deployer owns that; `env:true` flags it so reset can warn). The caller MUST
|
|
217
217
|
* have wiped every .enc token first — regenerating with tokens present would
|
package/dist/mcp-token.d.ts
CHANGED
|
@@ -88,7 +88,7 @@ export declare class ReplayGuard {
|
|
|
88
88
|
reserve(jti: string, ttlMs: number, nowMs: number): 'ok' | 'full';
|
|
89
89
|
/** Record a jti as spent. A full guard refuses instead of evicting a live
|
|
90
90
|
* jti: a flood of spends would otherwise make a spent artifact usable again,
|
|
91
|
-
* such as a callback the browser binding refused
|
|
91
|
+
* such as a callback the browser binding refused. `artifactExpMs`
|
|
92
92
|
* keeps the record alive as long as the artifact itself, even when the clock
|
|
93
93
|
* stepped back between its signing and this spend. */
|
|
94
94
|
spend(jti: string, ttlMs: number, nowMs: number, artifactExpMs?: number): ReplaySpend;
|
|
@@ -138,10 +138,10 @@ export interface RefreshStoreOptions {
|
|
|
138
138
|
* Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
|
|
139
139
|
* use, and a token carries its family and generation under a MAC, so
|
|
140
140
|
* presenting any generation the family has rotated past is REUSE: the family
|
|
141
|
-
* is revoked for the rest of its life
|
|
141
|
+
* is revoked for the rest of its life. The store keeps one
|
|
142
142
|
* record per family, whatever its rotation count. Every read-modify-write goes
|
|
143
143
|
* through a file lock so concurrent stdio+HTTP processes can't lost-update or
|
|
144
|
-
* double-spend
|
|
144
|
+
* double-spend. See docs/internals.md.
|
|
145
145
|
*/
|
|
146
146
|
export declare class RefreshStore {
|
|
147
147
|
private readonly path;
|
package/dist/mcp-token.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Token model: the MCP access
|
|
2
2
|
// token (HS256 JWT, jose-only — AS==RS so no JWKS), the signed self-contained
|
|
3
3
|
// `state` and authorization code, the in-memory single-use replay guard, and
|
|
4
|
-
// the rotated refresh-token store. Keyed by the provisioned MCP_JWT_KEY
|
|
5
|
-
//
|
|
4
|
+
// the rotated refresh-token store. Keyed by the provisioned MCP_JWT_KEY,
|
|
5
|
+
// so tokens survive a restart as long as the key persists.
|
|
6
6
|
import { createHash, createHmac, hkdfSync, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
7
7
|
import { existsSync, readFileSync } from 'node:fs';
|
|
8
8
|
import { basename } from 'node:path';
|
|
@@ -126,7 +126,7 @@ export class ReplayGuard {
|
|
|
126
126
|
}
|
|
127
127
|
/** Record a jti as spent. A full guard refuses instead of evicting a live
|
|
128
128
|
* jti: a flood of spends would otherwise make a spent artifact usable again,
|
|
129
|
-
* such as a callback the browser binding refused
|
|
129
|
+
* such as a callback the browser binding refused. `artifactExpMs`
|
|
130
130
|
* keeps the record alive as long as the artifact itself, even when the clock
|
|
131
131
|
* stepped back between its signing and this spend. */
|
|
132
132
|
spend(jti, ttlMs, nowMs, artifactExpMs = 0) {
|
|
@@ -378,10 +378,10 @@ function lifetimeMs(name, v) {
|
|
|
378
378
|
* Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
|
|
379
379
|
* use, and a token carries its family and generation under a MAC, so
|
|
380
380
|
* presenting any generation the family has rotated past is REUSE: the family
|
|
381
|
-
* is revoked for the rest of its life
|
|
381
|
+
* is revoked for the rest of its life. The store keeps one
|
|
382
382
|
* record per family, whatever its rotation count. Every read-modify-write goes
|
|
383
383
|
* through a file lock so concurrent stdio+HTTP processes can't lost-update or
|
|
384
|
-
* double-spend
|
|
384
|
+
* double-spend. See docs/internals.md.
|
|
385
385
|
*/
|
|
386
386
|
export class RefreshStore {
|
|
387
387
|
path;
|
package/dist/migrate-config.js
CHANGED
|
@@ -35,7 +35,7 @@ export function runMigrateConfig(env = process.env) {
|
|
|
35
35
|
}
|
|
36
36
|
desired[alias] = { email };
|
|
37
37
|
}
|
|
38
|
-
// Legacy global scopes
|
|
38
|
+
// Legacy global scopes become an explicit named profile every account
|
|
39
39
|
// points at, so removing the env var later changes nothing.
|
|
40
40
|
const legacyNames = parseCsv(env.GOOGLE_OPTIONAL_SCOPES);
|
|
41
41
|
let legacyProfile;
|
package/dist/oauth-as.d.ts
CHANGED
|
@@ -146,7 +146,7 @@ export interface AuthServer {
|
|
|
146
146
|
* start a re-auth nor learn which aliases exist or what they hold. */
|
|
147
147
|
reauthLink: (alias: string) => string;
|
|
148
148
|
}
|
|
149
|
-
/**
|
|
149
|
+
/** Exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
|
|
150
150
|
* the fixed claude.ai callback. */
|
|
151
151
|
export declare function redirectAllowed(redirectUri: string, docRedirectUris: string[]): boolean;
|
|
152
152
|
export declare function buildAuthServer(config: AuthServerConfig, deps?: AuthServerDeps): AuthServer;
|
package/dist/oauth-as.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// The minimal OAuth 2.1 Authorization Server + Resource Server.
|
|
2
|
+
// Federate-and-hold: the server mints its own
|
|
3
3
|
// audience-bound HS256 token for the MCP client (leg A), gates on an owner
|
|
4
4
|
// Google login (leg B, tokens discarded), and separately holds per-alias Google
|
|
5
|
-
// tokens (leg C, unchanged). Mounts into
|
|
5
|
+
// tokens (leg C, unchanged). Mounts into the HttpTransportHost as `routes` +
|
|
6
6
|
// an `authenticate` for POST /mcp. All Google/CIMD I/O is injectable so the
|
|
7
7
|
// whole surface is exercised by an offline harness.
|
|
8
8
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
@@ -25,7 +25,7 @@ const HOSTED_DOMAIN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i;
|
|
|
25
25
|
// RFC 6749's error_description charset: %x20-21 / %x23-5B / %x5D-7E (printable
|
|
26
26
|
// ASCII minus the backslash and double-quote that would need escaping in the
|
|
27
27
|
// query string). A resolveSubject denial outside this set is dropped rather
|
|
28
|
-
// than forwarded unvalidated
|
|
28
|
+
// than forwarded unvalidated.
|
|
29
29
|
const ERROR_DESCRIPTION_RE = /^[\x20-\x21\x23-\x5b\x5d-\x7e]{1,200}$/;
|
|
30
30
|
/** Only trust the id_token identity when Google marks the email verified (#8).
|
|
31
31
|
* The token comes straight from Google's token endpoint, so its claims are
|
|
@@ -90,7 +90,7 @@ function isLoopbackHostname(h) {
|
|
|
90
90
|
// prefix (which would let "127.evil.com" masquerade as loopback).
|
|
91
91
|
return isIPv4(host) && host.split('.')[0] === '127';
|
|
92
92
|
}
|
|
93
|
-
/**
|
|
93
|
+
/** Exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
|
|
94
94
|
* the fixed claude.ai callback. */
|
|
95
95
|
export function redirectAllowed(redirectUri, docRedirectUris) {
|
|
96
96
|
if (redirectUri === CLAUDE_AI_FIXED_CALLBACK)
|
|
@@ -256,7 +256,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
256
256
|
const reg = registered.get(clientId);
|
|
257
257
|
if (reg)
|
|
258
258
|
return { redirect_uris: reg.redirect_uris, dcr: true };
|
|
259
|
-
//
|
|
259
|
+
// Validate the issuer host + scheme BEFORE any outbound fetch, so we
|
|
260
260
|
// only ever fetch an allowlisted issuer (no arbitrary-URL SSRF surface).
|
|
261
261
|
let issuerHost;
|
|
262
262
|
let scheme;
|
|
@@ -281,7 +281,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
281
281
|
if ('error' in got)
|
|
282
282
|
return { error: got.error, message: 'invalid client' };
|
|
283
283
|
const uris = got.doc.redirect_uris;
|
|
284
|
-
//
|
|
284
|
+
// Warn on a localhost-only redirect set (loopback impersonation),
|
|
285
285
|
// once per fetch rather than on every sign-in the cache serves.
|
|
286
286
|
if (got.fresh && uris.every((u) => { try {
|
|
287
287
|
return isLoopbackHostname(new URL(u).hostname);
|
|
@@ -299,7 +299,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
299
299
|
/** `incoming`: the artifact spent to get here. If no slot is left for the
|
|
300
300
|
* new state it is unspent, so retrying it works once a slot frees. */
|
|
301
301
|
async function toGoogle(res, sp, incoming) {
|
|
302
|
-
//
|
|
302
|
+
// Without this, a Google URL minted here and forwarded to someone
|
|
303
303
|
// else signs THEM in and hands their subject to the starting client.
|
|
304
304
|
const headers = {};
|
|
305
305
|
if (sp.flow === 'owner_gate') {
|
|
@@ -388,7 +388,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
388
388
|
void _spent;
|
|
389
389
|
return toGoogle(res, sp, minted.jti);
|
|
390
390
|
}
|
|
391
|
-
// In-call re-auth
|
|
391
|
+
// In-call re-auth: a user-clicked `flow=alias_reauth` link has NO
|
|
392
392
|
// client leg — no code is delivered to any client, the refreshed token is
|
|
393
393
|
// written server-side for the alias — so it skips the client_id/redirect/
|
|
394
394
|
// PKCE requirements. The link must be one this server signed (reauthLink),
|
|
@@ -529,7 +529,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
529
529
|
if (st.flow === 'alias_reauth') {
|
|
530
530
|
if (!st.alias)
|
|
531
531
|
return errorPage(res, 400, 'invalid_request', 'alias_reauth without an alias');
|
|
532
|
-
//
|
|
532
|
+
// Identity binding: only overwrite the alias's tokens if the
|
|
533
533
|
// completing Google identity IS that alias's configured account — else an
|
|
534
534
|
// attacker who crafts an alias_reauth link and logs in with their OWN
|
|
535
535
|
// account could inject their tokens (account swap). The alias emails (leg
|
|
@@ -677,7 +677,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
677
677
|
}
|
|
678
678
|
return json(res, 400, { error: 'unsupported_grant_type', message: 'authorization_code or refresh_token only' });
|
|
679
679
|
};
|
|
680
|
-
// POST /register (minimal DCR
|
|
680
|
+
// POST /register (minimal DCR). Parse the raw JSON body directly (the
|
|
681
681
|
// redirect_uris array must survive intact).
|
|
682
682
|
const register = async (req, res) => {
|
|
683
683
|
let parsed;
|
package/dist/oauth-consent.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { OAuth2Client } from 'googleapis-common';
|
|
2
2
|
import http from 'node:http';
|
|
3
3
|
import { URL } from 'node:url';
|
|
4
|
-
// Shared loopback OAuth consent (leg C
|
|
5
|
-
//
|
|
6
|
-
// process.exit, and a timeout so a never-completed consent can't wedge a tool
|
|
4
|
+
// Shared loopback OAuth consent (leg C: per-alias Google tokens), used by the
|
|
5
|
+
// account wizard and the `auth --account` CLI. Live-server-safe: typed errors
|
|
6
|
+
// instead of process.exit, and a timeout so a never-completed consent can't wedge a tool
|
|
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.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Opt-in outbound recipient allowlist for unattended deployments: a
|
|
2
2
|
// prompt-injection blast-radius control (a hijacked agent cannot mail, invite
|
|
3
|
-
// or share outside the operator's list), a safety feature
|
|
4
|
-
//
|
|
3
|
+
// or share outside the operator's list), a safety feature,
|
|
4
|
+
// available in every install.
|
|
5
5
|
// Off by default: an unset/empty GOOGLE_OUTBOUND_ALLOWLIST gates nothing.
|
|
6
6
|
import { hints } from './hint-provider.js';
|
|
7
7
|
/** Parse GOOGLE_OUTBOUND_ALLOWLIST: comma-separated addresses and @domain
|
package/dist/reauth-hint.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// user cannot run a CLI, so the hint becomes a clickable, server-signed link
|
|
1
|
+
// In-call re-auth: a dead/missing per-account Google token is a leg-C
|
|
2
|
+
// (per-alias Google token) failure. Over stdio the fix is the `auth --account`
|
|
3
|
+
// CLI; over HTTP the user cannot run a CLI, so the hint becomes a clickable, server-signed link
|
|
4
4
|
// into the AS's clientless alias_reauth flow (AuthServer.reauthLink). The HTTP
|
|
5
5
|
// bootstrap installs the linker when the AS starts; stdio never does.
|
|
6
6
|
import { hints } from './hint-provider.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Refuse-only for v1: export/import operate on the FLAT single-owner
|
|
2
2
|
* store. A tenant-namespaced layout means the bundle would either miss every
|
|
3
3
|
* tenant's tokens or exfiltrate all of them in one file — refuse instead.
|
|
4
4
|
* Returns the offending dir, or null when the store is flat. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Encrypted registry export / import. Moves a whole
|
|
2
2
|
// setup to another machine. The bundle is encrypted under a user PASSPHRASE
|
|
3
3
|
// (portable across machines with different MASTER_KEYs) via the token-store AES
|
|
4
4
|
// primitives; the bundled <alias>.enc token files stay encrypted under the
|
|
@@ -11,7 +11,7 @@ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:
|
|
|
11
11
|
import { atomicWriteFileSync, atomicWriteWithLock } from './fs-atomic.js';
|
|
12
12
|
import { ALIAS_RE, configFilePath, CONFIG_VERSION, tenantsDir } from './config-file.js';
|
|
13
13
|
import { getTokenDir } from './accounts.js';
|
|
14
|
-
/**
|
|
14
|
+
/** Refuse-only for v1: export/import operate on the FLAT single-owner
|
|
15
15
|
* store. A tenant-namespaced layout means the bundle would either miss every
|
|
16
16
|
* tenant's tokens or exfiltrate all of them in one file — refuse instead.
|
|
17
17
|
* Returns the offending dir, or null when the store is flat. */
|
package/dist/registry.d.ts
CHANGED
|
@@ -106,7 +106,7 @@ export declare class ToolRegistry {
|
|
|
106
106
|
/** discover_all: advertise the full curated set at once. Idempotent. */
|
|
107
107
|
expand(): boolean;
|
|
108
108
|
/** discover_reset: back to the lean meta-only surface. Clears reveals too.
|
|
109
|
-
*
|
|
109
|
+
* If a client mishandles a SHRINKING tools/list, this degrades to a
|
|
110
110
|
* no-op for that session — tools stay callable regardless (graceful
|
|
111
111
|
* dispatch), zero correctness impact. */
|
|
112
112
|
collapse(): boolean;
|
package/dist/registry.js
CHANGED
|
@@ -81,7 +81,7 @@ function isAccountSelector(field) {
|
|
|
81
81
|
const def = field?._zod?.def;
|
|
82
82
|
if (!def)
|
|
83
83
|
return false;
|
|
84
|
-
//
|
|
84
|
+
// Account enums are .optional(); unwrap it or fan-out silently dies.
|
|
85
85
|
if (def.type === 'optional')
|
|
86
86
|
return def.innerType?._zod?.def?.type === 'enum';
|
|
87
87
|
return def.type === 'enum';
|
|
@@ -145,7 +145,7 @@ export class ToolRegistry {
|
|
|
145
145
|
idempotentHint: cud !== 'create',
|
|
146
146
|
...config.annotations,
|
|
147
147
|
};
|
|
148
|
-
//
|
|
148
|
+
// Forced per-call human approval on the irreversible set, even in
|
|
149
149
|
// bypass mode. Client-enforced via the wire _meta (Claude Code reads
|
|
150
150
|
// anthropic/* ONLY there); the server verdict stays separate.
|
|
151
151
|
// Every tool also advertises a result-size budget: fat readers declare
|
|
@@ -196,7 +196,7 @@ export class ToolRegistry {
|
|
|
196
196
|
: writeDisabledResult({ name, service, cud, scopes: config.requiredScopes }, policy,
|
|
197
197
|
// Concrete by here: default-account injection wraps this.
|
|
198
198
|
args[0]?.account);
|
|
199
|
-
//
|
|
199
|
+
// The ONE default-account injection site — outside the CUD gate and
|
|
200
200
|
// the fan-out parse so both observe a concrete alias; NOT meta-skipped
|
|
201
201
|
// (that is what covers google_api_call with zero bespoke code). Explicit
|
|
202
202
|
// aliases, "*" and CSV pass through untouched.
|
|
@@ -392,7 +392,7 @@ export class ToolRegistry {
|
|
|
392
392
|
return true;
|
|
393
393
|
}
|
|
394
394
|
/** discover_reset: back to the lean meta-only surface. Clears reveals too.
|
|
395
|
-
*
|
|
395
|
+
* If a client mishandles a SHRINKING tools/list, this degrades to a
|
|
396
396
|
* no-op for that session — tools stay callable regardless (graceful
|
|
397
397
|
* dispatch), zero correctness impact. */
|
|
398
398
|
collapse() {
|
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
|
@@ -63,6 +63,7 @@ export const BUNDLE_CATALOG = {
|
|
|
63
63
|
],
|
|
64
64
|
description: 'Manage Cloud Identity groups and devices across the directory.',
|
|
65
65
|
risk: 'high',
|
|
66
|
+
personalRefused: true,
|
|
66
67
|
},
|
|
67
68
|
cloudsearch: {
|
|
68
69
|
scopes: ['https://www.googleapis.com/auth/cloud_search'],
|
|
@@ -79,6 +80,7 @@ export const BUNDLE_CATALOG = {
|
|
|
79
80
|
scopes: ['https://www.googleapis.com/auth/keep'],
|
|
80
81
|
description: 'Read and edit Google Keep notes.',
|
|
81
82
|
risk: 'low',
|
|
83
|
+
personalRefused: true,
|
|
82
84
|
},
|
|
83
85
|
driveactivity: {
|
|
84
86
|
scopes: ['https://www.googleapis.com/auth/drive.activity.readonly'],
|
|
@@ -165,6 +167,17 @@ const warnedAliases = new Set();
|
|
|
165
167
|
export function isKnownBundle(name) {
|
|
166
168
|
return Object.hasOwn(BUNDLE_CATALOG, name);
|
|
167
169
|
}
|
|
170
|
+
const PERSONAL_ADDRESS_RE = /@(?:gmail|googlemail)\.com$/i;
|
|
171
|
+
/** A gmail.com or googlemail.com address, which is always a personal Google account. */
|
|
172
|
+
export function isPersonalGoogleAddress(email) {
|
|
173
|
+
return PERSONAL_ADDRESS_RE.test(email.trim());
|
|
174
|
+
}
|
|
175
|
+
/** The bundles in `bundles` that Google refuses on the consent screen of the account at `email`. */
|
|
176
|
+
export function refusedBundlesFor(email, bundles) {
|
|
177
|
+
if (!isPersonalGoogleAddress(email))
|
|
178
|
+
return [];
|
|
179
|
+
return bundles.filter((b) => isKnownBundle(b) && BUNDLE_CATALOG[b].personalRefused === true);
|
|
180
|
+
}
|
|
168
181
|
export function resolveBundleAliases(bundles) {
|
|
169
182
|
return bundles.map((name) => {
|
|
170
183
|
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
|
};
|
|
@@ -79,7 +79,7 @@ export function scopeHint(scope, c, alias) {
|
|
|
79
79
|
hint: rendered?.hint ??
|
|
80
80
|
(`Scope ${scope} is in ${alias}'s profile but not granted (you unchecked it at consent). ` +
|
|
81
81
|
reauthHint(alias)),
|
|
82
|
-
// The ONE kind whose retriable the host may flip
|
|
82
|
+
// The ONE kind whose retriable the host may flip: the
|
|
83
83
|
// owner keeps true, a multi-tenant host may report false because the
|
|
84
84
|
// fix needs the user in a browser.
|
|
85
85
|
retriable: rendered?.retriable ?? true,
|
package/dist/ssrf-guard.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// no redirects followed, and capped size and time. Blocks 10/8, 172.16/12,
|
|
1
|
+
// SSRF guard for every server-side fetch (the CIMD client_id doc).
|
|
2
|
+
// A small AUDITED private-range check, no dependency — HTTPS-only,
|
|
3
|
+
// resolve-then-check ALL addresses (anti-rebind), no redirects followed, and capped size and time. Blocks 10/8, 172.16/12,
|
|
5
4
|
// 192.168/16, 127/8, 169.254/16, 0/8, multicast, ::1, fc00::/7, fe80::/10, and
|
|
6
5
|
// v4-mapped forms.
|
|
7
6
|
import { lookup } from 'node:dns/promises';
|
package/dist/tenant-purge.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Tenant offboarding primitives. Core owns the reusable token-purge
|
|
2
2
|
// legs (generically useful, tenant-id-agnostic); the tenant_delete
|
|
3
3
|
// ORCHESTRATION (what to purge, in what order, from which admin surface)
|
|
4
4
|
// lives with the tenancy module that calls these.
|
package/dist/tools/_errors.js
CHANGED
|
@@ -134,7 +134,7 @@ function netCodeOf(error) {
|
|
|
134
134
|
}
|
|
135
135
|
return undefined;
|
|
136
136
|
}
|
|
137
|
-
/** Console deep-link to enable one API (noob-proofing hint
|
|
137
|
+
/** Console deep-link to enable one API (noob-proofing hint). */
|
|
138
138
|
function apiEnableLink(api) {
|
|
139
139
|
return `https://console.cloud.google.com/apis/library/${api}.googleapis.com`;
|
|
140
140
|
}
|
|
@@ -397,9 +397,9 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
|
|
|
397
397
|
hint: rendered?.hint ??
|
|
398
398
|
(`Network failure (${netCode}) before reaching Google - not an auth or API problem. Usually transient: retry. ` +
|
|
399
399
|
'If it persists on a high-latency or broken-IPv6 link, raise the happy-eyeballs budget: NODE_OPTIONS=--network-family-autoselection-attempt-timeout=4000 (server default 2000ms), and check connectivity with curl.'),
|
|
400
|
-
// Not an override site (
|
|
401
|
-
// may flip): a multi-tenant host keeps core's
|
|
402
|
-
// network_error.
|
|
400
|
+
// Not an override site (scope_not_granted is the only kind whose
|
|
401
|
+
// retriable the host may flip): a multi-tenant host keeps core's
|
|
402
|
+
// retriable codes for network_error.
|
|
403
403
|
retriable: RETRIABLE_NET_CODES.has(netCode),
|
|
404
404
|
account,
|
|
405
405
|
};
|
|
@@ -9,8 +9,9 @@ export interface AddForm {
|
|
|
9
9
|
otherBundles: string;
|
|
10
10
|
admin: boolean;
|
|
11
11
|
}
|
|
12
|
-
/** Every optional bundle (admin is granted via its own checkbox, not here)
|
|
13
|
-
|
|
12
|
+
/** Every optional bundle (admin is granted via its own checkbox, not here),
|
|
13
|
+
* less those Google refuses for the account at `email` when one is given. */
|
|
14
|
+
export declare function allOptionalBundles(email?: string): string[];
|
|
14
15
|
/** elicitation/create form schema (flat primitives only, per the MCP spec). */
|
|
15
16
|
export declare function addFormSchema(): Record<string, unknown>;
|
|
16
17
|
export type AddValidation = {
|
|
@@ -53,7 +54,7 @@ export declare function argsToAddForm(a: {
|
|
|
53
54
|
/** Scopes requested by the profile but NOT granted at consent (granular
|
|
54
55
|
* consent / unchecked bundles). Pure. */
|
|
55
56
|
export declare function scopeGrantDiff(requested: string[], grantedScope: string | undefined): string[];
|
|
56
|
-
/** HTTP-transport consent context
|
|
57
|
+
/** HTTP-transport consent context: set by the HTTP bootstrap. When
|
|
57
58
|
* present, runConsent NEVER binds a server-side loopback listener — the
|
|
58
59
|
* user's browser cannot reach the daemon's loopback — and instead hands out
|
|
59
60
|
* a clientless AS consent URL. The injector decides the flow (single-owner =
|
|
@@ -3,7 +3,7 @@ import { getAccountSet, invalidateAccountSet, refreshAccountSetIfStale } from '.
|
|
|
3
3
|
import { ALIAS_RE, mutateConfigFile } from '../config-file.js';
|
|
4
4
|
import { writeToken } from '../token-store.js';
|
|
5
5
|
import { resolveScopesForAccount } from '../auth.js';
|
|
6
|
-
import { BUNDLE_CATALOG, closestBundle, isKnownBundle, resolveBundleAliases } from '../scope-catalog.js';
|
|
6
|
+
import { BUNDLE_CATALOG, closestBundle, isKnownBundle, refusedBundlesFor, resolveBundleAliases } from '../scope-catalog.js';
|
|
7
7
|
import { openUrl } from '../open-url.js';
|
|
8
8
|
import { coerceBoolean } from './_coerce.js';
|
|
9
9
|
import { safeMessage, stringifyEnvelope } from './_errors.js';
|
|
@@ -13,9 +13,14 @@ import { detectClients, buildServerEntry, renderInstruction, applyFileEntry, res
|
|
|
13
13
|
// multi-select, so a boolean per bundle IS the checklist); the long tail stays
|
|
14
14
|
// a comma-separated field for power users, and `allBundles` grants every one.
|
|
15
15
|
const CHECKBOX_BUNDLES = ['forms', 'chat'];
|
|
16
|
-
/** Every optional bundle (admin is granted via its own checkbox, not here)
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
/** Every optional bundle (admin is granted via its own checkbox, not here),
|
|
17
|
+
* less those Google refuses for the account at `email` when one is given. */
|
|
18
|
+
export function allOptionalBundles(email) {
|
|
19
|
+
const all = Object.keys(BUNDLE_CATALOG).filter((n) => n !== 'admin');
|
|
20
|
+
if (email === undefined)
|
|
21
|
+
return all;
|
|
22
|
+
const refused = new Set(refusedBundlesFor(email, all));
|
|
23
|
+
return all.filter((n) => !refused.has(n));
|
|
19
24
|
}
|
|
20
25
|
/** elicitation/create form schema (flat primitives only, per the MCP spec). */
|
|
21
26
|
export function addFormSchema() {
|
|
@@ -49,7 +54,7 @@ export function validateAddForm(input, existingAliases) {
|
|
|
49
54
|
}
|
|
50
55
|
if (input.allBundles === true) {
|
|
51
56
|
// "All optional scopes" supersedes the individual picks.
|
|
52
|
-
return { ok: true, alias, email, bundles: allOptionalBundles(), admin: input.admin === true };
|
|
57
|
+
return { ok: true, alias, email, bundles: allOptionalBundles(email), admin: input.admin === true };
|
|
53
58
|
}
|
|
54
59
|
const picked = CHECKBOX_BUNDLES.filter((b) => input[b] === true);
|
|
55
60
|
const rawOther = (input.otherBundles ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
@@ -70,6 +75,16 @@ export function validateAddForm(input, existingAliases) {
|
|
|
70
75
|
};
|
|
71
76
|
}
|
|
72
77
|
}
|
|
78
|
+
const refused = refusedBundlesFor(email, bundles);
|
|
79
|
+
if (refused.length > 0) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
slug: 'E_VALIDATION',
|
|
83
|
+
message: `Google does not let a personal account grant ${refused.map((b) => `"${b}"`).join(', ')}.`,
|
|
84
|
+
hint: 'Leave it out, or link a Google Workspace account for it.',
|
|
85
|
+
alias,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
73
88
|
return { ok: true, alias, email, bundles, admin: input.admin === true };
|
|
74
89
|
}
|
|
75
90
|
/** URL-mode elicitation params. `elicitationId` is REQUIRED by the spec
|
|
@@ -103,7 +118,7 @@ export function scopeGrantDiff(requested, grantedScope) {
|
|
|
103
118
|
return requested.filter((s) => !granted.has(s));
|
|
104
119
|
}
|
|
105
120
|
/** Persist a new account row (+ a per-account scope profile when bundles/admin
|
|
106
|
-
* were chosen) through the atomic registry mutation path
|
|
121
|
+
* were chosen) through the atomic registry mutation path. */
|
|
107
122
|
function writeAccountRow(alias, email, bundles, admin) {
|
|
108
123
|
mutateConfigFile((current) => {
|
|
109
124
|
const next = { ...current, accounts: { ...(current.accounts ?? {}) } };
|
|
@@ -157,7 +172,7 @@ function pendingText(alias, url, pending) {
|
|
|
157
172
|
}
|
|
158
173
|
/** Run Google consent for `alias` and persist the token. Opens the browser via
|
|
159
174
|
* URL-mode elicitation when the client supports it, else server-side + prints
|
|
160
|
-
* the URL. Returns the granted-scope diff for the
|
|
175
|
+
* the URL. Returns the granted-scope diff for the validation report. */
|
|
161
176
|
async function runConsent(server, alias) {
|
|
162
177
|
const { randomBytes } = await import('node:crypto');
|
|
163
178
|
const cfg = getAccountSet().configs[alias];
|
|
@@ -274,14 +289,14 @@ export function registerAccountWizardTools(registry, server, awaitingRestart = (
|
|
|
274
289
|
try {
|
|
275
290
|
// Env-sourced registry: GOOGLE_ACCOUNTS is the exclusive source and
|
|
276
291
|
// config.json accounts are ignored, so a wizard add would be a phantom
|
|
277
|
-
// write. Refuse up front
|
|
292
|
+
// write. Refuse up front rather than write a row nothing reads.
|
|
278
293
|
if (process.env.GOOGLE_ACCOUNTS?.trim()) {
|
|
279
294
|
return errorResult('E_ENV_ACCOUNTS_MODE', 'Accounts are defined by the GOOGLE_ACCOUNTS environment variable, so a new account cannot be added interactively: config.json is ignored while it is set, and the row would be written where nothing reads it.', 'Either add the alias to GOOGLE_ACCOUNTS and run account_reauth, or run `mcp-google-multi migrate-config` to move accounts into config.json and unset GOOGLE_ACCOUNTS.');
|
|
280
295
|
}
|
|
281
296
|
if (!hasClientCredentials()) {
|
|
282
297
|
return errorResult('invalid_client', 'GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are not set, so there is no OAuth client to run consent against.', 'Run the `setup` prompt (/mcp__google-multi__setup) to create an OAuth client, then set them. See docs/google-cloud-setup.md.');
|
|
283
298
|
}
|
|
284
|
-
//
|
|
299
|
+
// Collect the registry row. Arguments win over the form so the
|
|
285
300
|
// wizard still works in clients without form elicitation (where the
|
|
286
301
|
// interactive path used to dead-end).
|
|
287
302
|
const a = (args ?? {});
|
|
@@ -304,7 +319,7 @@ export function registerAccountWizardTools(registry, server, awaitingRestart = (
|
|
|
304
319
|
const validated = validateAddForm(input, getAccountSet().aliases);
|
|
305
320
|
if (!validated.ok)
|
|
306
321
|
return errorResult(validated.slug, validated.message, validated.hint, validated.alias);
|
|
307
|
-
//
|
|
322
|
+
// Atomic write + make the alias callable without a restart.
|
|
308
323
|
writeAccountRow(validated.alias, validated.email, validated.bundles, validated.admin);
|
|
309
324
|
invalidateAccountSet();
|
|
310
325
|
// Live account args validate against the refreshed registry already;
|
|
@@ -316,13 +331,13 @@ export function registerAccountWizardTools(registry, server, awaitingRestart = (
|
|
|
316
331
|
catch {
|
|
317
332
|
// a client that cannot receive notifications loses nothing but the nudge
|
|
318
333
|
}
|
|
319
|
-
//
|
|
334
|
+
// Consent + validate.
|
|
320
335
|
const consent = await runConsent(server, validated.alias);
|
|
321
336
|
if (consent.ok === 'pending')
|
|
322
337
|
return textResult(pendingText(validated.alias, consent.url, awaitingRestart()));
|
|
323
338
|
if (!consent.ok)
|
|
324
339
|
return errorResult(consent.slug, consent.message, consent.hint, validated.alias);
|
|
325
|
-
//
|
|
340
|
+
// Offer to register the server with another MCP client.
|
|
326
341
|
return textResult(`${s4Text(validated.alias, consent.missing, awaitingRestart())}\nTip: run account_write_config to register this server with another MCP client (Claude Desktop / Cursor / Claude Code).`);
|
|
327
342
|
}
|
|
328
343
|
catch (e) {
|
|
@@ -12,7 +12,7 @@ export interface AccountHealthDeps {
|
|
|
12
12
|
fileExists: (p: string) => boolean;
|
|
13
13
|
now: () => number;
|
|
14
14
|
/** Scopes required by registered tools — feeds doctor's tool-grained
|
|
15
|
-
* "registered but not requestable" view
|
|
15
|
+
* "registered but not requestable" view. account_list stays compact:
|
|
16
16
|
* its report universe is profile ∪ granted only. */
|
|
17
17
|
registeredScopes?: () => string[];
|
|
18
18
|
/** Account view health is derived against; absent = the global registry. */
|
|
@@ -22,7 +22,7 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
|
|
|
22
22
|
const config = set.configs[alias];
|
|
23
23
|
const admin = getAdminAccounts(set).includes(alias);
|
|
24
24
|
const configured = resolveScopesForAccount(alias, set);
|
|
25
|
-
//
|
|
25
|
+
// Env-sourced accounts are not persisted in config.json, so the
|
|
26
26
|
// account wizard cannot edit them — the deployer edits env instead.
|
|
27
27
|
const base = {
|
|
28
28
|
alias,
|
package/dist/tools/admin.js
CHANGED
|
@@ -6,7 +6,7 @@ import { getClient } from '../client.js';
|
|
|
6
6
|
import { handleGoogleApiError, invalidParams } from './_errors.js';
|
|
7
7
|
// Admin SDK requires Workspace super-admin (or delegated admin) on the account — personal @gmail.com accounts 403 on every endpoint.
|
|
8
8
|
export function registerAdminTools(server, deps = {}) {
|
|
9
|
-
// Per-registry, LIVE account enum + injectable client
|
|
9
|
+
// Per-registry, LIVE account enum + injectable client: the
|
|
10
10
|
// schema follows the registry's account view at parse time, and the
|
|
11
11
|
// custody path is the context's, not the process global.
|
|
12
12
|
const accountEnum = accountArgLive(() => server.accountAliases()).optional();
|