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.
Files changed (69) hide show
  1. package/README.md +5 -5
  2. package/dist/accounts.d.ts +3 -3
  3. package/dist/accounts.js +11 -11
  4. package/dist/arg-strict.js +1 -1
  5. package/dist/auth.d.ts +1 -1
  6. package/dist/auth.js +3 -3
  7. package/dist/boot-gates.d.ts +1 -1
  8. package/dist/boot-gates.js +2 -2
  9. package/dist/cimd-cache.js +1 -1
  10. package/dist/client-config.d.ts +2 -2
  11. package/dist/client-config.js +4 -4
  12. package/dist/compose.js +1 -1
  13. package/dist/discover.js +2 -2
  14. package/dist/doctor.d.ts +2 -2
  15. package/dist/doctor.js +8 -9
  16. package/dist/http-config.d.ts +2 -2
  17. package/dist/http-config.js +4 -5
  18. package/dist/http-transport.d.ts +5 -5
  19. package/dist/http-transport.js +7 -7
  20. package/dist/identity.d.ts +2 -2
  21. package/dist/identity.js +4 -4
  22. package/dist/index.js +9 -9
  23. package/dist/master-key.d.ts +3 -3
  24. package/dist/master-key.js +5 -5
  25. package/dist/mcp-token.d.ts +3 -3
  26. package/dist/mcp-token.js +6 -6
  27. package/dist/migrate-config.js +1 -1
  28. package/dist/oauth-as.d.ts +1 -1
  29. package/dist/oauth-as.js +11 -11
  30. package/dist/oauth-consent.js +3 -3
  31. package/dist/outbound-allowlist.js +2 -2
  32. package/dist/reauth-hint.js +3 -3
  33. package/dist/registry-transfer.d.ts +1 -1
  34. package/dist/registry-transfer.js +2 -2
  35. package/dist/registry.d.ts +1 -1
  36. package/dist/registry.js +4 -4
  37. package/dist/scope-catalog.d.ts +5 -0
  38. package/dist/scope-catalog.js +13 -0
  39. package/dist/scope-observability.d.ts +1 -1
  40. package/dist/scope-observability.js +1 -1
  41. package/dist/ssrf-guard.js +3 -4
  42. package/dist/tenant-purge.js +1 -1
  43. package/dist/tools/_errors.js +4 -4
  44. package/dist/tools/account-wizard.d.ts +4 -3
  45. package/dist/tools/account-wizard.js +27 -12
  46. package/dist/tools/accounts-tool.d.ts +1 -1
  47. package/dist/tools/accounts-tool.js +1 -1
  48. package/dist/tools/admin.js +1 -1
  49. package/dist/tools/analytics.js +1 -1
  50. package/dist/tools/calendar.js +1 -1
  51. package/dist/tools/chat.js +1 -1
  52. package/dist/tools/contacts.js +4 -4
  53. package/dist/tools/docs.js +1 -1
  54. package/dist/tools/drive.js +1 -1
  55. package/dist/tools/forms.js +1 -1
  56. package/dist/tools/gmail-mime.d.ts +1 -1
  57. package/dist/tools/gmail-mime.js +3 -3
  58. package/dist/tools/gmail.d.ts +2 -2
  59. package/dist/tools/gmail.js +8 -8
  60. package/dist/tools/google-api.js +1 -1
  61. package/dist/tools/meet.js +1 -1
  62. package/dist/tools/searchconsole.js +1 -1
  63. package/dist/tools/sheets.js +1 -1
  64. package/dist/tools/slides.js +1 -1
  65. package/dist/tools/tasks.js +1 -1
  66. package/dist/usage-metrics.js +1 -1
  67. package/dist/write-control.d.ts +4 -5
  68. package/dist/write-control.js +1 -1
  69. package/package.json +1 -1
@@ -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
- /** BR-1 single choke point: no other module may read the env vars directly.
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
- * BR-5: after the FIRST successful token decrypt with an env-sourced key,
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
- /** B6 reset: drop the generated MASTER_KEY material (0600 file + keychain
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
@@ -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 BR-5 mirror once leaked a
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 is cc-security Cgen: minting a fresh
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
- /** BR-1 single choke point: no other module may read the env vars directly.
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
- * BR-5: after the FIRST successful token decrypt with an env-sourced key,
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
- /** B6 reset: drop the generated MASTER_KEY material (0600 file + keychain
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
@@ -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 (H4 F1). `artifactExpMs`
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 (#7 / C14, H4 F2). The store keeps one
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 (#12). See docs/internals.md.
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
- // B13 token model (oauth-authorization-server.md §Data model): the MCP access
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
- // (B5), so tokens survive a restart as long as the key persists.
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 (H4 F1). `artifactExpMs`
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 (#7 / C14, H4 F2). The store keeps one
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 (#12). See docs/internals.md.
384
+ * double-spend. See docs/internals.md.
385
385
  */
386
386
  export class RefreshStore {
387
387
  path;
@@ -35,7 +35,7 @@ export function runMigrateConfig(env = process.env) {
35
35
  }
36
36
  desired[alias] = { email };
37
37
  }
38
- // Legacy global scopes (BC7) become an explicit named profile every account
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;
@@ -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
- /** C6: exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
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
- // B13: the minimal OAuth 2.1 Authorization Server + Resource Server
2
- // (oauth-authorization-server.md). Federate-and-hold: the server mints its own
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 B12's HttpTransportHost as `routes` +
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 (H3 PR1).
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
- /** C6: exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
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
- // C7/#5: validate the issuer host + scheme BEFORE any outbound fetch, so we
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
- // C6 #14: warn on a localhost-only redirect set (loopback impersonation),
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
- // H4 F1: without this, a Google URL minted here and forwarded to someone
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 (BV-1): a user-clicked `flow=alias_reauth` link has NO
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
- // C13 / identity binding (#2/#6): only overwrite the alias's tokens if the
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, D2). Parse the raw JSON body directly (the
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;
@@ -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 of cc-auth), used by the account wizard
5
- // (B7) and the `auth --account` CLI. Live-server-safe: typed errors instead of
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 first and an
4
- // enterprise checkbox second. FREE-core forever (ee-definition-v0 handoff).
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
@@ -1,6 +1,6 @@
1
- // B13 in-call re-auth (BV-1): a dead/missing per-account Google token is a
2
- // leg-C failure. Over stdio the fix is the `auth --account` CLI; over HTTP the
3
- // user cannot run a CLI, so the hint becomes a clickable, 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
- /** D11 (refuse-only for v1): export/import operate on the FLAT single-owner
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
- // B19: encrypted registry export / import (account-registry.md). Moves a whole
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
- /** D11 (refuse-only for v1): export/import operate on the FLAT single-owner
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. */
@@ -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
- * BV-8: if a client mishandles a SHRINKING tools/list, this degrades to a
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
- // A2 made account enums .optional(); unwrap it or fan-out silently dies.
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
- // A12: forced per-call human approval on the irreversible set, even in
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
- // A2: the ONE default-account injection site — outside the CUD gate and
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
- * BV-8: if a client mishandles a SHRINKING tools/list, this degrades to a
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() {
@@ -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;
@@ -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 (B9) fills it from observed persistent admin 403s. */
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 (Decision 3): the
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,
@@ -1,7 +1,6 @@
1
- // B13 / C11: SSRF guard for every server-side fetch (the CIMD client_id doc).
2
- // D4 decision (dependencies-and-bv §2.2): a small AUDITED private-range check,
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,
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';
@@ -1,4 +1,4 @@
1
- // S1.21: tenant offboarding primitives. Core owns the reusable token-purge
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.
@@ -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, B10). */
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 (C5 is the only kind whose retriable the host
401
- // may flip): a multi-tenant host keeps core's retriable codes for
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
- export declare function allOptionalBundles(): string[];
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 (S1.20): set by the HTTP bootstrap. When
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
- export function allOptionalBundles() {
18
- return Object.keys(BUNDLE_CATALOG).filter((n) => n !== 'admin');
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 (BR2). */
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 S4 report. */
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 (BR-10) rather than write a row nothing reads.
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
- // S1: collect the registry row. Arguments win over the form so the
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
- // S2: atomic write + make the alias callable without a restart (BR3).
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
- // S3 + S4: consent + validate.
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
- // B15: offer to register the server with another MCP client.
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 (B9). account_list stays compact:
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
- // BR-10: env-sourced accounts are not persisted in config.json, so the
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,
@@ -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 (S1.10): the
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();