mcp-google-multi 5.4.1-alpha.2 → 6.0.0-alpha.2

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 (116) hide show
  1. package/README.md +21 -16
  2. package/dist/accounts.d.ts +44 -5
  3. package/dist/accounts.js +267 -25
  4. package/dist/auth.d.ts +11 -4
  5. package/dist/auth.js +85 -78
  6. package/dist/client-config.d.ts +90 -0
  7. package/dist/client-config.js +282 -0
  8. package/dist/client.js +10 -4
  9. package/dist/config-file.d.ts +38 -0
  10. package/dist/config-file.js +117 -0
  11. package/dist/discover.js +57 -3
  12. package/dist/doctor.d.ts +86 -0
  13. package/dist/doctor.js +384 -0
  14. package/dist/env-load.d.ts +11 -0
  15. package/dist/env-load.js +52 -0
  16. package/dist/executor.d.ts +2 -0
  17. package/dist/executor.js +2 -1
  18. package/dist/fanout.js +8 -5
  19. package/dist/fs-atomic.d.ts +3 -0
  20. package/dist/fs-atomic.js +123 -0
  21. package/dist/http-config.d.ts +35 -0
  22. package/dist/http-config.js +113 -0
  23. package/dist/http-transport.d.ts +59 -0
  24. package/dist/http-transport.js +269 -0
  25. package/dist/identity.d.ts +24 -0
  26. package/dist/identity.js +19 -0
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +199 -18
  29. package/dist/master-key.d.ts +53 -0
  30. package/dist/master-key.js +242 -0
  31. package/dist/mcp-token.d.ts +81 -0
  32. package/dist/mcp-token.js +176 -0
  33. package/dist/migrate-config.d.ts +7 -0
  34. package/dist/migrate-config.js +103 -0
  35. package/dist/migrate-tokens.js +4 -3
  36. package/dist/net-tuning.d.ts +8 -0
  37. package/dist/net-tuning.js +25 -0
  38. package/dist/oauth-as.d.ts +52 -0
  39. package/dist/oauth-as.js +457 -0
  40. package/dist/oauth-consent.d.ts +28 -0
  41. package/dist/oauth-consent.js +109 -0
  42. package/dist/open-url.d.ts +1 -0
  43. package/dist/open-url.js +30 -0
  44. package/dist/reauth-hint.d.ts +2 -0
  45. package/dist/reauth-hint.js +15 -0
  46. package/dist/registry-transfer.d.ts +41 -0
  47. package/dist/registry-transfer.js +219 -0
  48. package/dist/registry.d.ts +27 -1
  49. package/dist/registry.js +117 -6
  50. package/dist/scope-catalog.d.ts +19 -0
  51. package/dist/scope-catalog.js +182 -0
  52. package/dist/scope-observability.d.ts +78 -0
  53. package/dist/scope-observability.js +192 -0
  54. package/dist/services.js +1 -1
  55. package/dist/setup-prompt.d.ts +9 -0
  56. package/dist/setup-prompt.js +59 -0
  57. package/dist/ssrf-guard.d.ts +48 -0
  58. package/dist/ssrf-guard.js +242 -0
  59. package/dist/token-store.js +23 -125
  60. package/dist/tools/_errors.d.ts +8 -2
  61. package/dist/tools/_errors.js +97 -6
  62. package/dist/tools/account-wizard.d.ts +33 -0
  63. package/dist/tools/account-wizard.js +284 -0
  64. package/dist/tools/accounts-tool.d.ts +8 -5
  65. package/dist/tools/accounts-tool.js +38 -15
  66. package/dist/tools/admin.js +2 -2
  67. package/dist/tools/calendar.js +2 -2
  68. package/dist/tools/chat.js +2 -2
  69. package/dist/tools/contacts.d.ts +40 -0
  70. package/dist/tools/contacts.js +135 -2
  71. package/dist/tools/docs.d.ts +3 -0
  72. package/dist/tools/docs.js +60 -9
  73. package/dist/tools/drive.d.ts +3 -0
  74. package/dist/tools/drive.js +60 -8
  75. package/dist/tools/forms.js +4 -2
  76. package/dist/tools/generated/_shared.d.ts +1 -3
  77. package/dist/tools/generated/_shared.js +3 -3
  78. package/dist/tools/generated/admin.js +175 -132
  79. package/dist/tools/generated/appsmarket.js +6 -2
  80. package/dist/tools/generated/calendar.js +45 -27
  81. package/dist/tools/generated/chat.js +81 -47
  82. package/dist/tools/generated/classroom.js +131 -104
  83. package/dist/tools/generated/cloudidentity.js +73 -61
  84. package/dist/tools/generated/cloudsearch.js +59 -49
  85. package/dist/tools/generated/contacts.js +22 -14
  86. package/dist/tools/generated/drive.js +51 -35
  87. package/dist/tools/generated/driveactivity.js +5 -1
  88. package/dist/tools/generated/drivelabels.js +30 -25
  89. package/dist/tools/generated/forms.js +7 -3
  90. package/dist/tools/generated/gmail.js +73 -58
  91. package/dist/tools/generated/groupsmigration.js +5 -1
  92. package/dist/tools/generated/groupssettings.js +7 -3
  93. package/dist/tools/generated/keep.js +12 -7
  94. package/dist/tools/generated/licensing.js +11 -7
  95. package/dist/tools/generated/meet.js +20 -13
  96. package/dist/tools/generated/postmaster.js +8 -4
  97. package/dist/tools/generated/reseller.js +22 -17
  98. package/dist/tools/generated/script.js +26 -16
  99. package/dist/tools/generated/sheets.js +11 -7
  100. package/dist/tools/generated/tasks.js +6 -2
  101. package/dist/tools/generated/vault.js +38 -33
  102. package/dist/tools/generated/workspaceevents.js +12 -7
  103. package/dist/tools/gmail-mime.d.ts +38 -8
  104. package/dist/tools/gmail-mime.js +75 -71
  105. package/dist/tools/gmail.d.ts +31 -0
  106. package/dist/tools/gmail.js +483 -99
  107. package/dist/tools/google-api.js +7 -4
  108. package/dist/tools/meet.js +2 -2
  109. package/dist/tools/searchconsole.js +2 -2
  110. package/dist/tools/sheets.js +6 -2
  111. package/dist/tools/slides.js +4 -2
  112. package/dist/tools/tasks.js +2 -2
  113. package/dist/types.d.ts +1 -1
  114. package/dist/write-control.d.ts +24 -1
  115. package/dist/write-control.js +26 -1
  116. package/package.json +26 -7
@@ -0,0 +1,242 @@
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
+ // and cap redirects / size / time. Blocks 10/8, 172.16/12, 192.168/16, 127/8,
5
+ // 169.254/16, 0/8, multicast, ::1, fc00::/7, fe80::/10, and v4-mapped forms.
6
+ import { lookup } from 'node:dns/promises';
7
+ import { isIPv4, isIPv6, BlockList } from 'node:net';
8
+ export class SsrfBlockedError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = 'SsrfBlockedError';
12
+ }
13
+ }
14
+ /** True if an IPv4 literal is in a blocked (private/link-local/loopback/etc) range. */
15
+ export function isBlockedIPv4(ip) {
16
+ const parts = ip.split('.').map(Number);
17
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
18
+ return true;
19
+ const [a, b] = parts;
20
+ if (a === 0)
21
+ return true; // 0.0.0.0/8 "this network"
22
+ if (a === 10)
23
+ return true; // 10/8 private
24
+ if (a === 127)
25
+ return true; // 127/8 loopback
26
+ if (a === 169 && b === 254)
27
+ return true; // 169.254/16 link-local (incl. cloud metadata)
28
+ if (a === 172 && b >= 16 && b <= 31)
29
+ return true; // 172.16/12 private
30
+ if (a === 192 && b === 168)
31
+ return true; // 192.168/16 private
32
+ if (a === 100 && b >= 64 && b <= 127)
33
+ return true; // 100.64/10 CGNAT
34
+ if (a >= 224)
35
+ return true; // 224/4 multicast + 240/4 reserved
36
+ return false;
37
+ }
38
+ // Built-in subnet math (no hand-rolled IPv6 parsing): blocks loopback,
39
+ // unspecified, ULA, link-local, multicast, and ALL v4-mapped / v4-compatible /
40
+ // NAT64 / 6to4 embeddings (a public host never resolves to those, so blocking
41
+ // the whole range closes the embedded-private-target gap without parsing).
42
+ const V6_BLOCKED = new BlockList();
43
+ V6_BLOCKED.addSubnet('::', 96, 'ipv6'); // ::/96 = ::1, ::, and v4-compatible
44
+ V6_BLOCKED.addSubnet('::ffff:0:0', 96, 'ipv6'); // v4-mapped
45
+ V6_BLOCKED.addSubnet('64:ff9b::', 96, 'ipv6'); // NAT64
46
+ V6_BLOCKED.addSubnet('2002::', 16, 'ipv6'); // 6to4
47
+ V6_BLOCKED.addSubnet('fc00::', 7, 'ipv6'); // unique-local
48
+ V6_BLOCKED.addSubnet('fe80::', 10, 'ipv6'); // link-local
49
+ V6_BLOCKED.addSubnet('ff00::', 8, 'ipv6'); // multicast
50
+ /** True if an IPv6 literal is blocked. */
51
+ export function isBlockedIPv6(ip) {
52
+ const addr = ip.replace(/^\[|\]$/g, '');
53
+ if (!isIPv6(addr))
54
+ return true;
55
+ return V6_BLOCKED.check(addr, 'ipv6');
56
+ }
57
+ export function isBlockedIp(ip) {
58
+ if (isIPv4(ip))
59
+ return isBlockedIPv4(ip);
60
+ if (isIPv6(ip))
61
+ return isBlockedIPv6(ip);
62
+ return true; // unparseable → block
63
+ }
64
+ async function defaultResolveAll(host) {
65
+ // A bare IP literal needs no DNS; check it directly.
66
+ if (isIPv4(host) || isIPv6(host))
67
+ return [host];
68
+ const results = await lookup(host, { all: true });
69
+ return results.map((r) => r.address);
70
+ }
71
+ /**
72
+ * Assert a URL is safe to fetch server-side: HTTPS scheme, and every address
73
+ * the host resolves to is public. Throws SsrfBlockedError otherwise, and
74
+ * returns the resolved IPs.
75
+ *
76
+ * NOTE: this checks the host's addresses but does NOT pin them — the caller
77
+ * fetches by hostname, so undici re-resolves at connect time (a check-time vs
78
+ * connect-time TOCTOU / short-TTL rebind window). We deliberately do not pin
79
+ * (CDN IPs rotate). The real anti-SSRF control is the caller-side issuer
80
+ * allowlist (oauth-as validateClient rejects any non-allowlisted host BEFORE
81
+ * fetching), so an attacker can't steer the fetch at an arbitrary hostname;
82
+ * this public-range check is defense-in-depth on top of that.
83
+ */
84
+ export async function assertPublicHttpsUrl(rawUrl, deps = {}) {
85
+ let url;
86
+ try {
87
+ url = new URL(rawUrl);
88
+ }
89
+ catch {
90
+ throw new SsrfBlockedError(`not a valid URL: ${rawUrl}`);
91
+ }
92
+ if (url.protocol !== 'https:') {
93
+ throw new SsrfBlockedError(`only https is allowed (got ${url.protocol})`);
94
+ }
95
+ const resolveAll = deps.resolveAll ?? defaultResolveAll;
96
+ let addresses;
97
+ try {
98
+ addresses = await resolveAll(url.hostname);
99
+ }
100
+ catch (e) {
101
+ throw new SsrfBlockedError(`DNS resolution failed for ${url.hostname}: ${e.message}`);
102
+ }
103
+ if (addresses.length === 0)
104
+ throw new SsrfBlockedError(`no addresses resolved for ${url.hostname}`);
105
+ for (const ip of addresses) {
106
+ if (isBlockedIp(ip)) {
107
+ throw new SsrfBlockedError(`${url.hostname} resolves to a blocked address (${ip})`);
108
+ }
109
+ }
110
+ return { url, addresses };
111
+ }
112
+ // Connection-level failures that a retry can legitimately recover from. A dead
113
+ // IPv6 route (broken egress with Happy-Eyeballs falling through), a reset, or a
114
+ // DNS blip are transient; an SSRF block, a bad HTTP status, or malformed JSON
115
+ // are deterministic and must NOT be retried.
116
+ const TRANSIENT_CODES = new Set([
117
+ 'ETIMEDOUT',
118
+ 'ECONNRESET',
119
+ 'ECONNREFUSED',
120
+ 'EAI_AGAIN',
121
+ 'ENETUNREACH',
122
+ 'EHOSTUNREACH',
123
+ 'EPIPE',
124
+ 'UND_ERR_CONNECT_TIMEOUT',
125
+ 'UND_ERR_SOCKET',
126
+ ]);
127
+ function isTransientFetchError(e) {
128
+ if (e instanceof SsrfBlockedError)
129
+ return false; // deterministic security/shape reject
130
+ const name = e?.name;
131
+ if (name === 'AbortError' || name === 'TimeoutError')
132
+ return true; // our per-attempt timeout: dead/slow peer
133
+ const cause = e?.cause;
134
+ const code = cause?.code ?? e?.code;
135
+ if (code && TRANSIENT_CODES.has(code))
136
+ return true;
137
+ // undici surfaces connection failures as TypeError('fetch failed') with the
138
+ // real reason in .cause; retry even when the cause carries no useful code.
139
+ if (e instanceof TypeError && /fetch failed/i.test(e.message))
140
+ return true;
141
+ return false;
142
+ }
143
+ /**
144
+ * SSRF-guarded fetch of a CIMD client-metadata document (JSON). Every hop
145
+ * (including redirects) is HTTPS + public-IP checked; response is size- and
146
+ * time-capped. Returns the parsed JSON object.
147
+ */
148
+ export async function fetchCimdDocument(rawUrl, opts = {}) {
149
+ const maxBytes = opts.maxBytes ?? 64 * 1024;
150
+ const timeoutMs = opts.timeoutMs ?? 5000;
151
+ const maxRedirects = opts.maxRedirects ?? 3;
152
+ const retries = opts.retries ?? 2;
153
+ const backoffMs = opts.retryBackoffMs ?? 200;
154
+ const doFetch = opts.fetchImpl ?? fetch;
155
+ const sleep = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
156
+ let current = rawUrl;
157
+ for (let hop = 0; hop <= maxRedirects; hop++) {
158
+ let redirectTo = null;
159
+ let doc = null;
160
+ // Retry transient connection failures IN PLACE. Each attempt re-runs the
161
+ // resolve-then-check so a retry can never skip the anti-rebind guard, and
162
+ // the timer is per-attempt (armed across the body read, #10).
163
+ for (let attempt = 0;; attempt++) {
164
+ await assertPublicHttpsUrl(current, opts.ssrf);
165
+ const controller = new AbortController();
166
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
167
+ try {
168
+ const res = await doFetch(current, { redirect: 'manual', signal: controller.signal, headers: { accept: 'application/json' } });
169
+ if (res.status >= 300 && res.status < 400) {
170
+ const loc = res.headers.get('location');
171
+ if (!loc)
172
+ throw new SsrfBlockedError('redirect without a Location header');
173
+ redirectTo = loc;
174
+ break;
175
+ }
176
+ if (!res.ok)
177
+ throw new SsrfBlockedError(`CIMD fetch failed: HTTP ${res.status}`);
178
+ const text = await readCapped(res, maxBytes, controller);
179
+ let parsed;
180
+ try {
181
+ parsed = JSON.parse(text);
182
+ }
183
+ catch {
184
+ throw new SsrfBlockedError('CIMD document is not valid JSON');
185
+ }
186
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
187
+ throw new SsrfBlockedError('CIMD document is not a JSON object');
188
+ }
189
+ doc = parsed;
190
+ break;
191
+ }
192
+ catch (e) {
193
+ if (attempt < retries && isTransientFetchError(e)) {
194
+ await sleep(backoffMs * (attempt + 1));
195
+ continue;
196
+ }
197
+ throw e;
198
+ }
199
+ finally {
200
+ clearTimeout(timer);
201
+ }
202
+ }
203
+ if (redirectTo !== null) {
204
+ current = new URL(redirectTo, current).toString();
205
+ continue;
206
+ }
207
+ return doc;
208
+ }
209
+ throw new SsrfBlockedError(`too many redirects (> ${maxRedirects})`);
210
+ }
211
+ /** Read a response body, aborting the moment it exceeds maxBytes (never buffers
212
+ * an oversized body). Falls back to a capped arrayBuffer when there is no
213
+ * readable stream (e.g. a test Response). */
214
+ async function readCapped(res, maxBytes, controller) {
215
+ const reader = res.body?.getReader?.();
216
+ if (!reader) {
217
+ const buf = new Uint8Array(await res.arrayBuffer());
218
+ if (buf.byteLength > maxBytes)
219
+ throw new SsrfBlockedError(`CIMD document exceeds ${maxBytes} bytes`);
220
+ return new TextDecoder().decode(buf);
221
+ }
222
+ const chunks = [];
223
+ let size = 0;
224
+ for (;;) {
225
+ const { done, value } = await reader.read();
226
+ if (done)
227
+ break;
228
+ size += value.byteLength;
229
+ if (size > maxBytes) {
230
+ controller.abort();
231
+ throw new SsrfBlockedError(`CIMD document exceeds ${maxBytes} bytes`);
232
+ }
233
+ chunks.push(value);
234
+ }
235
+ const all = new Uint8Array(size);
236
+ let off = 0;
237
+ for (const c of chunks) {
238
+ all.set(c, off);
239
+ off += c.byteLength;
240
+ }
241
+ return new TextDecoder().decode(all);
242
+ }
@@ -1,13 +1,14 @@
1
1
  import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
- import path from 'node:path';
4
- import { ACCOUNT_CONFIG } from './accounts.js';
3
+ import { getAccountSet } from './accounts.js';
4
+ import { noteSuccessfulDecrypt, resolveMasterKeyForDispatch } from './master-key.js';
5
+ import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
5
6
  const ENC_VERSION = 1;
6
7
  const ALGO = 'aes-256-gcm';
7
- const LOCK_TIMEOUT_MS = 5_000;
8
- const LOCK_RETRY_MS = 10;
9
- const RENAME_ATTEMPTS = 5;
10
- const RENAME_RETRY_MS = 20;
8
+ // KDF is COMPAT-FROZEN: generated keys are randomBytes(32) base64 and take the
9
+ // raw path; only human-passphrase keys hit the bare-sha256 branch (v5 legacy —
10
+ // changing it would brick every existing token). HKDF + ENC_VERSION=2 is the
11
+ // sanctioned post-6.0 hardening route.
11
12
  export function deriveKey(masterKey) {
12
13
  if (!masterKey) {
13
14
  throw new Error('MASTER_KEY is required to encrypt/decrypt tokens. Generate one with: openssl rand -base64 32');
@@ -48,141 +49,38 @@ export function decryptToken(fileContents, masterKey) {
48
49
  return JSON.parse(plaintext.toString('utf8'));
49
50
  }
50
51
  function masterKey() {
51
- return process.env.MASTER_KEY ?? '';
52
+ return resolveMasterKeyForDispatch().key;
53
+ }
54
+ function encPath(alias) {
55
+ return getAccountSet().configs[alias].encPath;
52
56
  }
53
57
  export function readToken(alias) {
54
58
  let contents;
55
59
  try {
56
- contents = fs.readFileSync(ACCOUNT_CONFIG[alias].encPath, 'utf8');
60
+ contents = fs.readFileSync(encPath(alias), 'utf8');
57
61
  }
58
62
  catch {
59
63
  return null;
60
64
  }
61
- return decryptToken(contents, masterKey());
62
- }
63
- function sleep(ms) {
64
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
65
+ const data = decryptToken(contents, masterKey());
66
+ noteSuccessfulDecrypt();
67
+ return data;
65
68
  }
66
- function withTokenLock(alias, fn) {
67
- const p = ACCOUNT_CONFIG[alias].encPath;
68
- const dir = path.dirname(p);
69
- const lock = path.join(dir, `.${path.basename(p)}.lock`);
70
- const ownerFile = `${lock}.${process.pid}.${randomBytes(6).toString('hex')}.owner`;
71
- fs.mkdirSync(dir, { recursive: true });
72
- fs.writeFileSync(ownerFile, String(process.pid), { mode: 0o600, flag: 'wx' });
73
- const deadline = Date.now() + LOCK_TIMEOUT_MS;
74
- try {
75
- while (true) {
76
- try {
77
- fs.linkSync(ownerFile, lock);
78
- break;
79
- }
80
- catch (error) {
81
- const err = error;
82
- if (err.code !== 'EEXIST')
83
- throw error;
84
- try {
85
- const observedOwner = fs.readFileSync(lock, 'utf8');
86
- const owner = Number(observedOwner);
87
- // Non-PID content can only come from a corrupt or interrupted lock
88
- // write — recover it like a dead owner instead of spinning to timeout.
89
- let ownerDead = !(Number.isSafeInteger(owner) && owner > 0);
90
- if (!ownerDead) {
91
- try {
92
- process.kill(owner, 0);
93
- }
94
- catch (ownerError) {
95
- const code = ownerError.code;
96
- // EPERM: PID exists but is not signalable (recycled by another user);
97
- // treat as alive, never break a lock we cannot verify.
98
- if (code === 'ESRCH')
99
- ownerDead = true;
100
- else if (code !== 'EPERM')
101
- throw ownerError;
102
- }
103
- }
104
- if (ownerDead) {
105
- if (fs.readFileSync(lock, 'utf8') === observedOwner)
106
- fs.rmSync(lock, { force: true });
107
- continue;
108
- }
109
- }
110
- catch (readError) {
111
- if (readError.code === 'ENOENT') {
112
- continue;
113
- }
114
- throw readError;
115
- }
116
- if (Date.now() >= deadline) {
117
- throw new Error(`Timed out waiting for token lock: ${alias}`, { cause: error });
118
- }
119
- sleep(LOCK_RETRY_MS);
120
- }
121
- }
122
- }
123
- finally {
124
- fs.rmSync(ownerFile, { force: true });
125
- }
126
- try {
127
- return fn();
128
- }
129
- finally {
130
- fs.rmSync(lock, { force: true });
131
- }
69
+ // Lock + atomic-write mechanics live in fs-atomic.ts (generalized path-keyed
70
+ // from this file's original alias-keyed implementation; behavior unchanged).
71
+ export function writeToken(alias, data) {
72
+ withFileLock(encPath(alias), () => writeTokenAtomic(alias, data), `token lock: ${alias}`);
132
73
  }
133
74
  function writeTokenAtomic(alias, data) {
134
- const p = ACCOUNT_CONFIG[alias].encPath;
135
- const dir = path.dirname(p);
136
- const tmp = path.join(dir, `.${path.basename(p)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
137
- try {
138
- fs.writeFileSync(tmp, encryptToken(data, masterKey()), { mode: 0o600, flag: 'wx' });
139
- // Open read-write, not read-only: on Windows fsync maps to FlushFileBuffers,
140
- // which returns EPERM on a read-only handle.
141
- const fd = fs.openSync(tmp, 'r+');
142
- try {
143
- fs.fsyncSync(fd);
144
- }
145
- finally {
146
- fs.closeSync(fd);
147
- }
148
- renameWithRetry(tmp, p);
149
- }
150
- finally {
151
- try {
152
- fs.rmSync(tmp, { force: true });
153
- }
154
- catch {
155
- // force only suppresses ENOENT; a Windows handle-holder can make this
156
- // throw and mask the real write error. The orphan tmp is harmless.
157
- }
158
- }
159
- }
160
- // Windows only: renaming over a momentarily-open file throws transient EPERM/EACCES/EBUSY (reads take no lock); see docs/internals.md.
161
- function renameWithRetry(from, to) {
162
- for (let attempt = 1;; attempt++) {
163
- try {
164
- fs.renameSync(from, to);
165
- return;
166
- }
167
- catch (error) {
168
- const code = error.code;
169
- const transient = code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
170
- if (!transient || attempt >= RENAME_ATTEMPTS)
171
- throw error;
172
- sleep(RENAME_RETRY_MS * attempt);
173
- }
174
- }
175
- }
176
- export function writeToken(alias, data) {
177
- withTokenLock(alias, () => writeTokenAtomic(alias, data));
75
+ atomicWriteFileSync(encPath(alias), encryptToken(data, masterKey()), 0o600);
178
76
  }
179
77
  export function updateToken(alias, updates) {
180
- withTokenLock(alias, () => {
78
+ withFileLock(encPath(alias), () => {
181
79
  const existing = readToken(alias) ?? {};
182
80
  const definedUpdates = Object.fromEntries(Object.entries(updates).filter(([, value]) => value !== null && value !== undefined));
183
81
  writeTokenAtomic(alias, { ...existing, ...definedUpdates });
184
- });
82
+ }, `token lock: ${alias}`);
185
83
  }
186
84
  export function hasToken(alias) {
187
- return fs.existsSync(ACCOUNT_CONFIG[alias].encPath);
85
+ return fs.existsSync(encPath(alias));
188
86
  }
@@ -6,8 +6,14 @@ export interface ErrorEnvelope {
6
6
  retriable: boolean;
7
7
  account: string;
8
8
  }
9
- export declare function mapGoogleError(error: any, account: Account, forbiddenHint?: string): ErrorEnvelope;
10
- export declare function handleGoogleApiError(error: any, account: Account, forbiddenHint?: string): {
9
+ export declare function mapGoogleError(error: any, account: Account, forbiddenHint?: string, scopeContext?: () => {
10
+ hint: string;
11
+ retriable: boolean;
12
+ } | null): ErrorEnvelope;
13
+ export declare function handleGoogleApiError(error: any, account: Account, forbiddenHint?: string, scopeContext?: () => {
14
+ hint: string;
15
+ retriable: boolean;
16
+ } | null): {
11
17
  content: {
12
18
  type: "text";
13
19
  text: string;
@@ -1,3 +1,4 @@
1
+ import { reauthHint } from '../reauth-hint.js';
1
2
  function statusOf(error) {
2
3
  const c = error?.code ?? error?.status ?? error?.response?.status;
3
4
  const n = typeof c === 'string' ? Number(c) : c;
@@ -11,29 +12,106 @@ function reasonOf(error) {
11
12
  function messageOf(error) {
12
13
  return error?.response?.data?.error?.message ?? error?.message ?? String(error);
13
14
  }
14
- export function mapGoogleError(error, account, forbiddenHint) {
15
+ // Connect/DNS syscall codes. ENOTFOUND (no such name) is the one non-transient
16
+ // member. node-fetch flattens the happy-eyeballs AggregateError to a bare code
17
+ // with an empty message, so the code is the only surviving signal to surface.
18
+ const RETRIABLE_NET_CODES = new Set([
19
+ 'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'ECONNABORTED', 'ENETUNREACH',
20
+ 'EHOSTUNREACH', 'EPIPE', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET',
21
+ ]);
22
+ const NET_CODES = new Set([...RETRIABLE_NET_CODES, 'ENOTFOUND']);
23
+ /** First known network code on the error or its cause chain (GaxiosError.cause
24
+ * -> FetchError; undici TypeError.cause -> AggregateError.errors). */
25
+ function netCodeOf(error) {
26
+ for (let e = error, depth = 0; e && depth < 5; e = e.cause ?? e.error, depth++) {
27
+ if (typeof e.code === 'string' && NET_CODES.has(e.code))
28
+ return e.code;
29
+ if (Array.isArray(e.errors)) {
30
+ const sub = e.errors.find((x) => typeof x?.code === 'string' && NET_CODES.has(x.code));
31
+ if (sub)
32
+ return sub.code;
33
+ }
34
+ }
35
+ return undefined;
36
+ }
37
+ /** Console deep-link to enable one API (noob-proofing hint, B10). */
38
+ function apiEnableLink(api) {
39
+ return `https://console.cloud.google.com/apis/library/${api}.googleapis.com`;
40
+ }
41
+ /** Extract the disabled API id from an accessNotConfigured / SERVICE_DISABLED
42
+ * error so the hint can deep-link straight to its enable page. */
43
+ function disabledApiId(message) {
44
+ const url = message.match(/\/apis\/api\/([a-z0-9-]+)\.googleapis\.com/i);
45
+ if (url)
46
+ return url[1].toLowerCase();
47
+ const named = message.match(/\b([A-Za-z][A-Za-z0-9 ]*?) API has not been used/);
48
+ if (named)
49
+ return named[1].trim().toLowerCase().replace(/\s+/g, '');
50
+ return null;
51
+ }
52
+ export function mapGoogleError(error, account, forbiddenHint, scopeContext) {
15
53
  const status = statusOf(error);
16
54
  const reason = reasonOf(error);
17
55
  const message = messageOf(error);
56
+ // 7-day Testing-mode trap: a BYO OAuth client in "Testing" status expires
57
+ // refresh tokens weekly; the dead token surfaces as invalid_grant. Name the
58
+ // real fix (publish to production) so the agent stops looping on re-auth.
59
+ // Checked before the generic 401 so it wins on either 400 or 401.
60
+ const rawError = error?.response?.data?.error;
61
+ const grantHaystack = [message, reason, typeof rawError === 'string' ? rawError : '', error?.response?.data?.error_description]
62
+ .filter(Boolean)
63
+ .join(' ');
64
+ if ((status === 400 || status === 401) && /invalid_grant/i.test(grantHaystack)) {
65
+ return {
66
+ error: 'reauth_required',
67
+ message,
68
+ hint: `Refresh token for "${account}" is dead (often the 7-day Testing-mode trap). ` +
69
+ 'Set Publishing status to In production at https://console.cloud.google.com/auth/audience, ' +
70
+ `then ${reauthHint(account)}`,
71
+ retriable: false,
72
+ account,
73
+ };
74
+ }
18
75
  if (status === 401) {
19
76
  return {
20
77
  error: 'auth_required',
21
78
  message: `Authentication failed for account "${account}".`,
22
- hint: `Run: npx mcp-google-multi auth --account ${account}`,
79
+ hint: reauthHint(account),
23
80
  retriable: false,
24
81
  account,
25
82
  };
26
83
  }
27
84
  if (status === 403) {
85
+ // API not enabled for the project: a distinct, self-serve fix (enable the
86
+ // API) rather than a scope/permission dead-end.
87
+ const notEnabled = reason === 'accessNotConfigured' ||
88
+ reason === 'SERVICE_DISABLED' ||
89
+ /has not been used in project|accessNotConfigured|SERVICE_DISABLED|it is disabled/i.test(message);
90
+ if (notEnabled) {
91
+ const api = disabledApiId(message);
92
+ return {
93
+ error: 'api_not_enabled',
94
+ message,
95
+ hint: api
96
+ ? `Enable this API for your project: ${apiEnableLink(api)}`
97
+ : 'Enable the API for your project at https://console.cloud.google.com/apis/library',
98
+ retriable: false,
99
+ account,
100
+ };
101
+ }
28
102
  const scopeIssue = reason === 'insufficientPermissions' ||
29
103
  reason === 'ACCESS_TOKEN_SCOPE_INSUFFICIENT' ||
30
104
  /insufficient.*scope/i.test(message);
31
105
  if (scopeIssue) {
106
+ // #114 three-state enrichment when the method's required scopes are
107
+ // known (escape hatch / generated tools): state-specific remediation and
108
+ // an honest retriable so agents stop retrying dead ends.
109
+ const enriched = scopeContext?.() ?? null;
32
110
  return {
33
111
  error: 'insufficient_scope',
34
112
  message,
35
- hint: forbiddenHint ?? `Re-auth "${account}" with the scope this operation needs.`,
36
- retriable: false,
113
+ hint: enriched?.hint ?? forbiddenHint ?? `Re-auth "${account}" with the scope this operation needs.`,
114
+ retriable: enriched?.retriable ?? false,
37
115
  account,
38
116
  };
39
117
  }
@@ -58,10 +136,23 @@ export function mapGoogleError(error, account, forbiddenHint) {
58
136
  if (status !== undefined && status >= 500) {
59
137
  return { error: 'upstream_error', message, retriable: true, account };
60
138
  }
139
+ if (status === undefined) {
140
+ const netCode = netCodeOf(error);
141
+ if (netCode) {
142
+ return {
143
+ error: 'network_error',
144
+ message: message.includes(netCode) ? message : message.endsWith('reason: ') ? `${message}${netCode}` : `${message} (${netCode})`,
145
+ hint: `Network failure (${netCode}) before reaching Google - not an auth or API problem. Usually transient: retry. ` +
146
+ '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.',
147
+ retriable: RETRIABLE_NET_CODES.has(netCode),
148
+ account,
149
+ };
150
+ }
151
+ }
61
152
  return { error: 'upstream_error', message, retriable: false, account };
62
153
  }
63
- export function handleGoogleApiError(error, account, forbiddenHint) {
64
- const envelope = mapGoogleError(error, account, forbiddenHint);
154
+ export function handleGoogleApiError(error, account, forbiddenHint, scopeContext) {
155
+ const envelope = mapGoogleError(error, account, forbiddenHint, scopeContext);
65
156
  return {
66
157
  content: [{ type: 'text', text: JSON.stringify(envelope) }],
67
158
  isError: true,
@@ -0,0 +1,33 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { ToolRegistry } from '../registry.js';
3
+ export interface AddForm {
4
+ alias: string;
5
+ email: string;
6
+ allBundles: boolean;
7
+ forms: boolean;
8
+ chat: boolean;
9
+ otherBundles: string;
10
+ admin: boolean;
11
+ }
12
+ /** Every optional bundle (admin is granted via its own checkbox, not here). */
13
+ export declare function allOptionalBundles(): string[];
14
+ /** elicitation/create form schema (flat primitives only, per the MCP spec). */
15
+ export declare function addFormSchema(): Record<string, unknown>;
16
+ export type AddValidation = {
17
+ ok: true;
18
+ alias: string;
19
+ email: string;
20
+ bundles: string[];
21
+ admin: boolean;
22
+ } | {
23
+ ok: false;
24
+ slug: string;
25
+ message: string;
26
+ };
27
+ /** Validate the collected form against the alias rules, dup check, and the
28
+ * bundle catalog. Pure (no I/O) for unit testing. */
29
+ export declare function validateAddForm(input: Partial<AddForm>, existingAliases: string[]): AddValidation;
30
+ /** Scopes requested by the profile but NOT granted at consent (granular
31
+ * consent / unchecked bundles). Pure. */
32
+ export declare function scopeGrantDiff(requested: string[], grantedScope: string | undefined): string[];
33
+ export declare function registerAccountWizardTools(registry: ToolRegistry, server: McpServer): void;