mcp-google-multi 6.0.0-alpha.64 → 6.0.0-alpha.65

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.
@@ -1,5 +1,5 @@
1
1
  import { echoName, echoNames, screenArguments, unknownArgEnvelope, } from './arg-strict.js';
2
- import { sliceClean } from './trim.js';
2
+ import { logSafe, sliceClean } from './trim.js';
3
3
  // The SDK synthesizes input-validation failures as isError RESULTS, skipping
4
4
  // the handler entirely: no handler of ours runs, so no envelope exists and the
5
5
  // client gets free text. The text is prose in both SDK eras (v1 prefixed it
@@ -115,8 +115,9 @@ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}
115
115
  if (screened.unknown.length === 0 && screened.redundant.length === 0)
116
116
  return { action: 'forward', msg };
117
117
  const all = [...screened.unknown.map((u) => u.sent), ...screened.redundant];
118
- // Key names only; argument VALUES never reach the log.
119
- log(`[args] ${tool}: undeclared ${echoNames(all)}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
118
+ // Key names only; argument VALUES never reach the log. The names are the
119
+ // caller's, so they are escaped here and only here: the envelope is JSON.
120
+ log(`[args] ${tool}: undeclared ${logSafe(echoNames(all), 600)}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
120
121
  try {
121
122
  // Only ever a DECLARED key or the literal placeholder, so the metrics
122
123
  // closed-vocabulary rule holds: the caller's key is never persisted.
@@ -0,0 +1,38 @@
1
+ export declare const CIMD_TTL_MS: number;
2
+ /** The oldest validated document still served while the cap is full or the issuer cannot answer. */
3
+ export declare const CIMD_MAX_STALE_MS: number;
4
+ /** Fetches at once. A lookup can outlive the fetch that gave up on it, so ssrf-guard caps its own lookups apart. */
5
+ export declare const CIMD_MAX_IN_FLIGHT = 4;
6
+ export declare const CIMD_CACHE_MAX = 256;
7
+ export type CimdDocument = Record<string, unknown>;
8
+ type CimdError = {
9
+ error: 'E_CIMD_SSRF_BLOCKED' | 'E_CIMD_INVALID';
10
+ };
11
+ /** `fresh` is true only for the request whose fetch produced the document. */
12
+ export type CimdResolution = {
13
+ doc: CimdDocument;
14
+ fresh: boolean;
15
+ } | {
16
+ busy: true;
17
+ } | CimdError;
18
+ export interface CimdCacheDeps {
19
+ fetch: (clientId: string) => Promise<CimdDocument>;
20
+ now: () => number;
21
+ log: (line: string) => void;
22
+ }
23
+ export declare class CimdClientCache {
24
+ private readonly deps;
25
+ private readonly entries;
26
+ private readonly inFlight;
27
+ private busyLoggedAt;
28
+ private busySuppressed;
29
+ constructor(deps: CimdCacheDeps);
30
+ get size(): number;
31
+ /** `validate` runs once per fetch, in the request that started it, and every
32
+ * request waiting on that fetch gets the same answer. */
33
+ resolve(clientId: string, validate: (doc: CimdDocument) => boolean): Promise<CimdResolution>;
34
+ private fetchFor;
35
+ private store;
36
+ private noteBusy;
37
+ }
38
+ export {};
@@ -0,0 +1,117 @@
1
+ // H4 F6: GET /authorize is unauthenticated, and a client_id on an allowlisted
2
+ // issuer with a path never seen before forces an outbound fetch. So the
3
+ // fetches running at once are capped, one client_id is fetched once however
4
+ // many requests wait on it, and only a document that passed validation is
5
+ // kept. Errors are never cached (CIMD section 5.2), and a negative cache would
6
+ // not help anyway: the attack varies the path, so its key never repeats.
7
+ import { CimdHttpError, isTransientFetchError, SsrfBlockedError } from './ssrf-guard.js';
8
+ import { logSafe } from './trim.js';
9
+ export const CIMD_TTL_MS = 5 * 60_000;
10
+ /** The oldest validated document still served while the cap is full or the issuer cannot answer. */
11
+ export const CIMD_MAX_STALE_MS = 24 * 60 * 60_000;
12
+ /** Fetches at once. A lookup can outlive the fetch that gave up on it, so ssrf-guard caps its own lookups apart. */
13
+ export const CIMD_MAX_IN_FLIGHT = 4;
14
+ export const CIMD_CACHE_MAX = 256;
15
+ const BUSY_LOG_EVERY_MS = 60_000;
16
+ export class CimdClientCache {
17
+ deps;
18
+ entries = new Map();
19
+ // One entry per running fetch, so its size is the number of permits taken.
20
+ inFlight = new Map();
21
+ busyLoggedAt = -Infinity;
22
+ busySuppressed = 0;
23
+ constructor(deps) {
24
+ this.deps = deps;
25
+ }
26
+ get size() {
27
+ return this.entries.size;
28
+ }
29
+ /** `validate` runs once per fetch, in the request that started it, and every
30
+ * request waiting on that fetch gets the same answer. */
31
+ async resolve(clientId, validate) {
32
+ const entry = this.entries.get(clientId);
33
+ const age = entry ? this.deps.now() - entry.at : 0;
34
+ if (entry && age < CIMD_TTL_MS)
35
+ return { doc: entry.doc, fresh: false };
36
+ if (entry && age >= CIMD_MAX_STALE_MS)
37
+ this.entries.delete(clientId);
38
+ const running = this.inFlight.get(clientId);
39
+ if (running) {
40
+ const out = await running;
41
+ return 'doc' in out ? { doc: out.doc, fresh: false } : out;
42
+ }
43
+ if (this.inFlight.size >= CIMD_MAX_IN_FLIGHT) {
44
+ // A flood of fresh paths is exactly when the cap is full, and a client
45
+ // that signed in before must still be able to.
46
+ const stale = this.entries.get(clientId);
47
+ if (stale)
48
+ return { doc: stale.doc, fresh: false };
49
+ this.noteBusy();
50
+ return { busy: true };
51
+ }
52
+ return this.fetchFor(clientId, validate);
53
+ }
54
+ async fetchFor(clientId, validate) {
55
+ // Never rejects: neither core nor a host installs an unhandledRejection
56
+ // handler, and a waiter that stopped listening must not take the process down.
57
+ let settle;
58
+ this.inFlight.set(clientId, new Promise((r) => (settle = r)));
59
+ let out = { error: 'E_CIMD_INVALID' };
60
+ try {
61
+ const doc = await this.deps.fetch(clientId);
62
+ if (validate(doc)) {
63
+ this.store(clientId, doc);
64
+ out = { doc, fresh: true };
65
+ }
66
+ else {
67
+ this.entries.delete(clientId);
68
+ }
69
+ }
70
+ catch (e) {
71
+ out = { error: e instanceof SsrfBlockedError ? 'E_CIMD_SSRF_BLOCKED' : 'E_CIMD_INVALID' };
72
+ // A document that went bad is never served stale. An issuer that is down
73
+ // or overloaded says nothing about the document, so the last good one
74
+ // stays and answers this fetch's requests too (stale-if-error; the error
75
+ // itself is never stored).
76
+ const stale = this.entries.get(clientId);
77
+ if (!saysNothingAboutDocument(e))
78
+ this.entries.delete(clientId);
79
+ else if (stale && this.deps.now() - stale.at < CIMD_MAX_STALE_MS)
80
+ out = { doc: stale.doc, fresh: false };
81
+ // #13: the detail goes to the log only; the caller gets a generic answer.
82
+ this.deps.log(`CIMD fetch failed for ${logSafe(clientId, 128)}: ${logSafe(e instanceof Error ? e.message : 'non-Error rejection', 200)}`);
83
+ }
84
+ finally {
85
+ this.inFlight.delete(clientId);
86
+ settle(out);
87
+ }
88
+ return out;
89
+ }
90
+ store(clientId, doc) {
91
+ this.entries.delete(clientId);
92
+ while (this.entries.size >= CIMD_CACHE_MAX) {
93
+ const oldest = this.entries.keys().next().value;
94
+ if (oldest === undefined)
95
+ break;
96
+ this.entries.delete(oldest);
97
+ }
98
+ this.entries.set(clientId, { doc, at: this.deps.now() });
99
+ }
100
+ // Over-cap requests arrive at whatever rate the caller likes.
101
+ noteBusy() {
102
+ const now = this.deps.now();
103
+ if (now - this.busyLoggedAt < BUSY_LOG_EVERY_MS) {
104
+ this.busySuppressed++;
105
+ return;
106
+ }
107
+ const suppressed = this.busySuppressed;
108
+ this.busyLoggedAt = now;
109
+ this.busySuppressed = 0;
110
+ this.deps.log(`CIMD busy: in-flight cap reached${suppressed > 0 ? ` (n=${suppressed} suppressed)` : ''}`);
111
+ }
112
+ }
113
+ function saysNothingAboutDocument(e) {
114
+ if (isTransientFetchError(e))
115
+ return true;
116
+ return e instanceof CimdHttpError && (e.status === 408 || e.status === 429 || e.status >= 500);
117
+ }
@@ -2,6 +2,7 @@ import type { McpServer, Transport } from "@modelcontextprotocol/server";
2
2
  import { type IncomingMessage, type ServerResponse } from 'node:http';
3
3
  import type { HttpConfig } from './http-config.js';
4
4
  import { type ArgShape, type StrictArgOptions, type ValidationEnvelopeOptions } from './arg-normalize.js';
5
+ export { logSafe } from './trim.js';
5
6
  export type AuthOutcome = {
6
7
  ok: true;
7
8
  sub?: string;
@@ -73,7 +74,9 @@ export declare function parseOwnerEmails(env?: NodeJS.ProcessEnv): string[];
73
74
  * connector calls /mcp server-to-server with no Origin, BV-4). */
74
75
  export declare function originAllowed(origin: string | undefined, allowed: string[]): boolean;
75
76
  export declare function hostAllowed(host: string | undefined, allowed: string[]): boolean;
76
- /** JSON-RPC method name(s) for an observability log line — no params, no PII. */
77
+ /** JSON-RPC method name(s) for an observability log line: no params, no PII.
78
+ * The method is the caller's: anything not shaped like a method name, which
79
+ * includes a value whose String() throws, is logged as `<invalid>`. */
77
80
  export declare function jsonRpcMethod(body: unknown): string;
78
81
  export declare class HttpTransportHost {
79
82
  private readonly opts;
@@ -5,6 +5,9 @@ import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
5
5
  // loopback-owner authenticator so the local-HTTP model works before the AS lands.
6
6
  import { createServer } from 'node:http';
7
7
  import { withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
8
+ import { logSafe } from './trim.js';
9
+ // A host wrapping the log callbacks it hands core needs this, and trim has no package export.
10
+ export { logSafe } from './trim.js';
8
11
  // A hung handler that keeps the connection open would otherwise hold the global
9
12
  // serialize() lock forever. Generous by default so slow-but-valid calls (large
10
13
  // Drive exports, fan-out) still finish; the point is only to guarantee release.
@@ -37,12 +40,25 @@ export function hostAllowed(host, allowed) {
37
40
  const bare = host.replace(/:\d+$/, '');
38
41
  return allowed.includes(host) || allowed.includes(bare);
39
42
  }
40
- /** JSON-RPC method name(s) for an observability log line — no params, no PII. */
43
+ const METHOD_NAME = /^[A-Za-z0-9_./$-]{1,64}$/;
44
+ const BATCH_METHODS_SHOWN = 4;
45
+ /** JSON-RPC method name(s) for an observability log line: no params, no PII.
46
+ * The method is the caller's: anything not shaped like a method name, which
47
+ * includes a value whose String() throws, is logged as `<invalid>`. */
41
48
  export function jsonRpcMethod(body) {
42
- const one = (b) => b && typeof b === 'object' && 'method' in b ? String(b.method) : undefined;
43
- if (Array.isArray(body))
44
- return body.map(one).filter(Boolean).join(',') || 'batch';
45
- return one(body) ?? 'unknown';
49
+ const one = (b) => {
50
+ if (!b || typeof b !== 'object' || !('method' in b))
51
+ return undefined;
52
+ const m = b.method;
53
+ return typeof m === 'string' && METHOD_NAME.test(m) ? m : '<invalid>';
54
+ };
55
+ if (!Array.isArray(body))
56
+ return one(body) ?? 'unknown';
57
+ const names = body.map(one).filter((n) => n !== undefined);
58
+ if (names.length === 0)
59
+ return 'batch';
60
+ const rest = names.length - BATCH_METHODS_SHOWN;
61
+ return rest > 0 ? `${names.slice(0, BATCH_METHODS_SHOWN).join(',')},+${rest}` : names.join(',');
46
62
  }
47
63
  /** setTimeout fires at once for a delay above this, so a longer grace would
48
64
  * cut every connection immediately. */
@@ -160,7 +176,7 @@ export class HttpTransportHost {
160
176
  finally {
161
177
  // A throwing log must not reach the destroy below: the answer is sent.
162
178
  try {
163
- this.log(`500 internal_error path=${pathOf(req)}: ${e instanceof Error ? e.message : 'non-Error throw'}`);
179
+ this.log(`500 internal_error path=${pathOf(req)}: ${e instanceof Error ? logSafe(e.message, 200) : 'non-Error throw'}`);
164
180
  }
165
181
  catch {
166
182
  // nowhere left to report it
@@ -292,12 +308,12 @@ export class HttpTransportHost {
292
308
  frontGuard(req, res) {
293
309
  const { allowedHosts, allowedOrigins } = this.opts.config;
294
310
  if (!hostAllowed(req.headers.host, allowedHosts)) {
295
- this.log(`403 host_rejected host=${req.headers.host ?? ''}`);
311
+ this.log(`403 host_rejected host=${logSafe(req.headers.host ?? '', 64)}`);
296
312
  this.fail(res, 403, 'host_rejected', 'Host not allowed (DNS-rebinding guard).');
297
313
  return false;
298
314
  }
299
315
  if (!originAllowed(req.headers.origin, allowedOrigins)) {
300
- this.log(`403 origin_rejected origin=${req.headers.origin ?? ''}`);
316
+ this.log(`403 origin_rejected origin=${logSafe(req.headers.origin ?? '', 64)}`);
301
317
  this.fail(res, 403, 'origin_rejected', 'Origin not allowed.');
302
318
  return false;
303
319
  }
package/dist/oauth-as.js CHANGED
@@ -8,7 +8,9 @@
8
8
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
9
9
  import { isIPv4 } from 'node:net';
10
10
  import { signAccessToken, verifyAccessToken, signState, verifyState, signAuthzCode, verifyAuthzCode, signReauthLink, verifyReauthLink, signPending, verifyPending, ReplayGuard, RefreshStore, STATE_TTL_DEFAULT, CODE_TTL_DEFAULT, ACCESS_TTL_DEFAULT, } from './mcp-token.js';
11
- import { fetchCimdDocument, SsrfBlockedError } from './ssrf-guard.js';
11
+ import { fetchCimdDocument } from './ssrf-guard.js';
12
+ import { CimdClientCache } from './cimd-cache.js';
13
+ import { logSafe } from './trim.js';
12
14
  const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
13
15
  // DCR (/register) is public + unauthenticated, so its in-memory store must be
14
16
  // bounded like every sibling store (the ReplayGuard cap) or a request loop
@@ -61,6 +63,10 @@ class SubjectCheckUnavailable extends Error {
61
63
  function subjectUnavailable(res) {
62
64
  return json(res, 503, { error: 'temporarily_unavailable', message: 'the sign-in could not be checked right now; retry shortly' }, { 'Retry-After': '5' });
63
65
  }
66
+ // Not invalid_client: clients and people read that as a permanent misconfiguration.
67
+ function clientUnavailable(res) {
68
+ return json(res, 503, { error: 'temporarily_unavailable', message: 'the client could not be checked right now; retry shortly' }, { 'Retry-After': '5' });
69
+ }
64
70
  function redirect(res, location, extraHeaders = {}) {
65
71
  res.writeHead(302, { Location: location, ...extraHeaders });
66
72
  res.end();
@@ -210,8 +216,7 @@ export function buildAuthServer(config, deps = {}) {
210
216
  authorization_response_iss_parameter_supported: true,
211
217
  scopes_supported: ['mcp:use'],
212
218
  };
213
- const cimdCache = new Map();
214
- const CIMD_CACHE_TTL_MS = 5 * 60_000;
219
+ const cimdClients = new CimdClientCache({ fetch: cimd, now, log });
215
220
  async function validateClient(clientId) {
216
221
  const reg = registered.get(clientId);
217
222
  if (reg)
@@ -229,40 +234,27 @@ export function buildAuthServer(config, deps = {}) {
229
234
  return { error: 'E_CIMD_INVALID', message: 'invalid client' };
230
235
  }
231
236
  if (scheme !== 'https:' || !config.cimdIssuers.includes(issuerHost)) {
232
- log(`CIMD rejected: client_id ${clientId} issuer not allowlisted`);
237
+ // The issuer and a length, never the path: an attacker picks the path freely.
238
+ log(`CIMD rejected: issuer=${logSafe(`${scheme}//${issuerHost}`, 80)} not allowlisted client_id_len=${clientId.length}`);
233
239
  return { error: 'E_CIMD_INVALID', message: 'invalid client' };
234
240
  }
235
- let doc;
236
- const cached = cimdCache.get(clientId);
237
- if (cached && cached.exp > now()) {
238
- doc = cached.doc;
239
- }
240
- else {
241
- try {
242
- doc = await cimd(clientId);
243
- }
244
- catch (e) {
245
- // #13: never echo internal fetch details to the caller (blind-SSRF
246
- // oracle); log server-side, return a generic message.
247
- log(`CIMD fetch failed for ${clientId}: ${e.message}`);
248
- const slug = e instanceof SsrfBlockedError ? 'E_CIMD_SSRF_BLOCKED' : 'E_CIMD_INVALID';
249
- return { error: slug, message: 'invalid client' };
250
- }
251
- cimdCache.set(clientId, { doc, exp: now() + CIMD_CACHE_TTL_MS }); // #15
252
- }
253
- if (doc.client_id !== clientId)
254
- return { error: 'E_CIMD_INVALID', message: 'invalid client' };
255
- const uris = Array.isArray(doc.redirect_uris) ? doc.redirect_uris : [];
256
- if (uris.length === 0)
257
- return { error: 'E_CIMD_INVALID', message: 'invalid client' };
258
- // C6 #14: warn on a localhost-only redirect set (loopback impersonation).
259
- if (uris.every((u) => { try {
241
+ // #15: cached only once it validates; the cache logs a failed fetch.
242
+ const got = await cimdClients.resolve(clientId, (doc) => doc.client_id === clientId && Array.isArray(doc.redirect_uris) && doc.redirect_uris.length > 0);
243
+ if ('busy' in got)
244
+ return got;
245
+ // #13: never echo internal fetch details to the caller (blind-SSRF oracle).
246
+ if ('error' in got)
247
+ return { error: got.error, message: 'invalid client' };
248
+ const uris = got.doc.redirect_uris;
249
+ // C6 #14: warn on a localhost-only redirect set (loopback impersonation),
250
+ // once per fetch rather than on every sign-in the cache serves.
251
+ if (got.fresh && uris.every((u) => { try {
260
252
  return isLoopbackHostname(new URL(u).hostname);
261
253
  }
262
254
  catch {
263
255
  return false;
264
256
  } })) {
265
- log(`client ${clientId} advertises only loopback redirect_uris`);
257
+ log(`client ${logSafe(clientId, 128)} advertises only loopback redirect_uris`);
266
258
  }
267
259
  return { redirect_uris: uris, dcr: false };
268
260
  }
@@ -366,6 +358,8 @@ export function buildAuthServer(config, deps = {}) {
366
358
  if (!clientId)
367
359
  return json(res, 400, { error: 'invalid_request', message: 'client_id required', iss: base }, issHeader);
368
360
  const client = await validateClient(clientId);
361
+ if ('busy' in client)
362
+ return clientUnavailable(res);
369
363
  if ('error' in client)
370
364
  return json(res, 400, { error: 'invalid_client', message: client.message, iss: base }, issHeader);
371
365
  // (2) exact redirect_uri match BEFORE minting anything (open-redirect defense)
@@ -446,7 +440,7 @@ export function buildAuthServer(config, deps = {}) {
446
440
  catch (e) {
447
441
  // The detail can name proxies and hosts; it goes to the log, not the browser.
448
442
  const detail = e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection';
449
- log(`callback exchange failed flow=${st.flow}: ${detail.replace(/[\r\n]+/g, ' ')}`);
443
+ log(`callback exchange failed flow=${st.flow}: ${logSafe(detail, 300)}`);
450
444
  return errorPage(res, 400, 'invalid_grant', 'Google could not complete the sign-in; start again from your app');
451
445
  }
452
446
  if (st.flow === 'alias_add') {
@@ -464,17 +458,17 @@ export function buildAuthServer(config, deps = {}) {
464
458
  }
465
459
  catch (e) {
466
460
  // Any rejection reason, even none, must still reach this answer.
467
- log(`alias_add bind failed for "${st.alias}": ${e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection'}`);
461
+ log(`alias_add bind failed for "${logSafe(st.alias, 64)}": ${logSafe(e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection', 200)}`);
468
462
  return errorPage(res, 500, 'E_ALIAS_ADD_FAILED', 'the account could not be saved; try the link again or ask for a fresh one');
469
463
  }
470
464
  if (refusal) {
471
465
  // The slug lands in a text/plain body; keep it a bare identifier.
472
466
  const slug = typeof refusal.slug === 'string' && /^[A-Za-z0-9_]+$/.test(refusal.slug) ? refusal.slug : 'access_denied';
473
467
  const message = typeof refusal.message === 'string' && refusal.message ? refusal.message : 'the account could not be linked';
474
- log(`alias_add refused for "${st.alias}": ${slug}`);
468
+ log(`alias_add refused for "${logSafe(st.alias, 64)}": ${logSafe(slug, 64)}`);
475
469
  return errorPage(res, 403, slug, message);
476
470
  }
477
- log(`callback ok flow=alias_add alias=${st.alias}`);
471
+ log(`callback ok flow=alias_add alias=${logSafe(st.alias, 64)}`);
478
472
  res.writeHead(200, { 'Content-Type': 'text/html' });
479
473
  res.end(`<!doctype html><meta charset=utf-8><p>Connected "${escapeHtml(st.alias)}". You can close this window and retry your request.</p>`);
480
474
  return true;
@@ -490,7 +484,7 @@ export function buildAuthServer(config, deps = {}) {
490
484
  const gotEmail = (exchanged.email ?? '').toLowerCase();
491
485
  const expected = (deps.aliasEmail?.(st.alias) ?? '').toLowerCase();
492
486
  if (!expected || !gotEmail || gotEmail !== expected) {
493
- log(`alias_reauth identity mismatch for "${st.alias}" (got ${gotEmail || 'none'})`);
487
+ log(`alias_reauth identity mismatch for "${logSafe(st.alias, 64)}" (got ${logSafe(gotEmail || 'none', 254)})`);
494
488
  return errorPage(res, 403, 'access_denied', 'the Google account you signed in with is not the one configured for this alias');
495
489
  }
496
490
  // A completion without a refresh token (a Google URL built without
@@ -498,7 +492,7 @@ export function buildAuthServer(config, deps = {}) {
498
492
  // dies within the hour. Keep the stored token instead.
499
493
  const refreshToken = exchanged.tokens.refresh_token;
500
494
  if (typeof refreshToken !== 'string' || refreshToken === '') {
501
- log(`alias_reauth for "${st.alias}" returned no refresh token; stored token kept`);
495
+ log(`alias_reauth for "${logSafe(st.alias, 64)}" returned no refresh token; stored token kept`);
502
496
  return errorPage(res, 400, 'E_REAUTH_INCOMPLETE', 'Google did not return a long-lived token, so the stored one was kept. Open the re-auth link again and approve access.');
503
497
  }
504
498
  const granted = typeof exchanged.tokens.scope === 'string' ? exchanged.tokens.scope : undefined;
@@ -508,13 +502,13 @@ export function buildAuthServer(config, deps = {}) {
508
502
  // for an hour and the Google URL's scope is not signed, so a narrower
509
503
  // grant is not necessarily the account holder's choice.
510
504
  if (missing.length > 0 && deps.hasToken?.(st.alias)) {
511
- log(`alias_reauth for "${st.alias}" granted ${missing.length} fewer scope(s); stored token kept`);
505
+ log(`alias_reauth for "${logSafe(st.alias, 64)}" granted ${missing.length} fewer scope(s); stored token kept`);
512
506
  res.writeHead(400, { 'Content-Type': 'text/html' });
513
507
  res.end(`<!doctype html><meta charset=utf-8><p>E_SCOPE_NOT_GRANTED: Google did not grant ${missing.length} requested scope(s): ${listed}, so the stored access for "${escapeHtml(st.alias)}" was kept. Ask for a fresh re-auth link and leave every box ticked.</p>`);
514
508
  return true;
515
509
  }
516
510
  deps.writeToken?.(st.alias, exchanged.tokens);
517
- log(`callback ok flow=alias_reauth alias=${st.alias}`);
511
+ log(`callback ok flow=alias_reauth alias=${logSafe(st.alias, 64)}`);
518
512
  // With no stored token a partial grant still beats none; say what is missing.
519
513
  const gap = missing.length
520
514
  ? `<p>Google did not grant ${missing.length} requested scope(s): ${listed}. Ask for a fresh re-auth link and leave every box ticked to restore them.</p>`
@@ -577,7 +571,7 @@ export function buildAuthServer(config, deps = {}) {
577
571
  active = deps.subjectActive(code.sub);
578
572
  }
579
573
  catch (e) {
580
- log(`token deferred grant=authorization_code: subject check failed: ${e instanceof Error ? e.message : 'non-Error throw'}`);
574
+ log(`token deferred grant=authorization_code: subject check failed: ${e instanceof Error ? logSafe(e.message, 200) : 'non-Error throw'}`);
581
575
  return subjectUnavailable(res);
582
576
  }
583
577
  if (!active) {
@@ -612,7 +606,7 @@ export function buildAuthServer(config, deps = {}) {
612
606
  catch (e) {
613
607
  if (!(e instanceof SubjectCheckUnavailable))
614
608
  throw e;
615
- log(`token deferred grant=refresh_token: subject check failed: ${e.cause instanceof Error ? e.cause.message : 'non-Error throw'}`);
609
+ log(`token deferred grant=refresh_token: subject check failed: ${e.cause instanceof Error ? logSafe(e.cause.message, 200) : 'non-Error throw'}`);
616
610
  return subjectUnavailable(res);
617
611
  }
618
612
  if (!next)
@@ -633,9 +627,18 @@ export function buildAuthServer(config, deps = {}) {
633
627
  catch {
634
628
  return json(res, 400, { error: 'invalid_client_metadata', message: 'a JSON body with redirect_uris is required' });
635
629
  }
636
- const redirectUris = Array.isArray(parsed.redirect_uris) ? parsed.redirect_uris.map(String) : [];
630
+ // Shape first: a throw here is an unauthenticated 500 per request, and a
631
+ // `null` body has no properties while String() throws on {"toString":1}.
632
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
633
+ return json(res, 400, { error: 'invalid_client_metadata', message: 'a JSON body with redirect_uris is required' });
634
+ }
635
+ const raw = parsed.redirect_uris;
636
+ const redirectUris = Array.isArray(raw) ? raw : [];
637
637
  if (redirectUris.length === 0)
638
638
  return json(res, 400, { error: 'invalid_client_metadata', message: 'redirect_uris required' });
639
+ if (!redirectUris.every((u) => typeof u === 'string')) {
640
+ return json(res, 400, { error: 'invalid_client_metadata', message: 'every redirect_uri must be a string' });
641
+ }
639
642
  if (redirectUris.length > DCR_MAX_REDIRECT_URIS || redirectUris.some((u) => u.length > DCR_MAX_URI_LEN)) {
640
643
  return json(res, 400, { error: 'invalid_client_metadata', message: 'too many or oversized redirect_uris' });
641
644
  }
@@ -1,6 +1,12 @@
1
1
  export declare class SsrfBlockedError extends Error {
2
2
  constructor(message: string);
3
3
  }
4
+ /** A non-2xx answer, kept apart so the cache can tell an issuer that is
5
+ * overloaded for now from one that says the document is gone. */
6
+ export declare class CimdHttpError extends SsrfBlockedError {
7
+ readonly status: number;
8
+ constructor(status: number);
9
+ }
4
10
  /** True if an IPv4 literal is in a blocked (private/link-local/loopback/etc) range. */
5
11
  export declare function isBlockedIPv4(ip: string): boolean;
6
12
  /** True if an IPv6 literal is blocked. */
@@ -10,28 +16,35 @@ export interface SsrfDeps {
10
16
  /** Injectable resolver (defaults to dns.lookup all-addresses) for tests. */
11
17
  resolveAll?: (host: string) => Promise<string[]>;
12
18
  }
19
+ /** The most lookups started here that may be unsettled at once, abandoned ones included; process-wide, as the libuv threadpool is. */
20
+ export declare const MAX_UNSETTLED_LOOKUPS = 4;
13
21
  /**
14
22
  * Assert a URL is safe to fetch server-side: HTTPS scheme, and every address
15
- * the host resolves to is public. Throws SsrfBlockedError otherwise, and
16
- * returns the resolved IPs.
23
+ * the host resolves to is public. Throws SsrfBlockedError otherwise, except
24
+ * that a resolver error with a transient code (a lookup that could not answer
25
+ * this time) is rethrown as is. Returns the resolved IPs.
17
26
  *
18
27
  * NOTE: this checks the host's addresses but does NOT pin them — the caller
19
28
  * fetches by hostname, so undici re-resolves at connect time (a check-time vs
20
29
  * connect-time TOCTOU / short-TTL rebind window). We deliberately do not pin
21
30
  * (CDN IPs rotate). The real anti-SSRF control is the caller-side issuer
22
31
  * allowlist (oauth-as validateClient rejects any non-allowlisted host BEFORE
23
- * fetching), so an attacker can't steer the fetch at an arbitrary hostname;
24
- * this public-range check is defense-in-depth on top of that.
32
+ * fetching, and fetchCimdDocument follows no redirect, so no hop leaves it),
33
+ * so an attacker can't steer the fetch at an arbitrary hostname; this
34
+ * public-range check is defense-in-depth on top of that.
25
35
  */
26
36
  export declare function assertPublicHttpsUrl(rawUrl: string, deps?: SsrfDeps): Promise<{
27
37
  url: URL;
28
38
  addresses: string[];
29
39
  }>;
30
40
  export interface CimdFetchOptions {
41
+ /** Default 5120: CIMD section 8.7 recommends at most 5 KB. */
31
42
  maxBytes?: number;
43
+ /** One attempt's cap, never past what is left of deadlineMs (default 5000). */
32
44
  timeoutMs?: number;
33
- maxRedirects?: number;
34
- /** Transient-network-error retries per hop (default 2 → up to 3 attempts). */
45
+ /** One budget for every attempt, DNS lookups and backoff included (default 8000). */
46
+ deadlineMs?: number;
47
+ /** Transient-network-error retries (default 1, so at most 2 attempts). */
35
48
  retries?: number;
36
49
  /** Base backoff between retries in ms; grows linearly per attempt (default 200). */
37
50
  retryBackoffMs?: number;
@@ -40,9 +53,10 @@ export interface CimdFetchOptions {
40
53
  sleepImpl?: (ms: number) => Promise<void>;
41
54
  ssrf?: SsrfDeps;
42
55
  }
56
+ export declare function isTransientFetchError(e: unknown): boolean;
43
57
  /**
44
- * SSRF-guarded fetch of a CIMD client-metadata document (JSON). Every hop
45
- * (including redirects) is HTTPS + public-IP checked; response is size- and
46
- * time-capped. Returns the parsed JSON object.
58
+ * SSRF-guarded fetch of a CIMD client-metadata document (JSON): HTTPS and
59
+ * public-IP checked, no redirect followed, size-capped, and every attempt
60
+ * inside one deadline. Returns the parsed JSON object.
47
61
  */
48
62
  export declare function fetchCimdDocument(rawUrl: string, opts?: CimdFetchOptions): Promise<Record<string, unknown>>;
@@ -1,8 +1,9 @@
1
1
  // B13 / C11: SSRF guard for every server-side fetch (the CIMD client_id doc).
2
2
  // D4 decision (dependencies-and-bv §2.2): a small AUDITED private-range check,
3
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.
4
+ // no redirects followed, and capped size and time. Blocks 10/8, 172.16/12,
5
+ // 192.168/16, 127/8, 169.254/16, 0/8, multicast, ::1, fc00::/7, fe80::/10, and
6
+ // v4-mapped forms.
6
7
  import { lookup } from 'node:dns/promises';
7
8
  import { isIPv4, isIPv6, BlockList } from 'node:net';
8
9
  export class SsrfBlockedError extends Error {
@@ -11,6 +12,16 @@ export class SsrfBlockedError extends Error {
11
12
  this.name = 'SsrfBlockedError';
12
13
  }
13
14
  }
15
+ /** A non-2xx answer, kept apart so the cache can tell an issuer that is
16
+ * overloaded for now from one that says the document is gone. */
17
+ export class CimdHttpError extends SsrfBlockedError {
18
+ status;
19
+ constructor(status) {
20
+ super(`CIMD fetch failed: HTTP ${status}`);
21
+ this.name = 'CimdHttpError';
22
+ this.status = status;
23
+ }
24
+ }
14
25
  /** True if an IPv4 literal is in a blocked (private/link-local/loopback/etc) range. */
15
26
  export function isBlockedIPv4(ip) {
16
27
  const parts = ip.split('.').map(Number);
@@ -61,25 +72,45 @@ export function isBlockedIp(ip) {
61
72
  return isBlockedIPv6(ip);
62
73
  return true; // unparseable → block
63
74
  }
75
+ /** The most lookups started here that may be unsettled at once, abandoned ones included; process-wide, as the libuv threadpool is. */
76
+ export const MAX_UNSETTLED_LOOKUPS = 4;
77
+ let unsettledLookups = 0;
78
+ /** The lookup cap refused to start one: transient for the cache, but never retried, since the hung lookups it waits on outlast any backoff. */
79
+ class LookupBusyError extends Error {
80
+ code = 'EAI_AGAIN';
81
+ }
64
82
  async function defaultResolveAll(host) {
65
83
  // A bare IP literal needs no DNS; check it directly.
66
84
  if (isIPv4(host) || isIPv6(host))
67
85
  return [host];
68
- const results = await lookup(host, { all: true });
69
- return results.map((r) => r.address);
86
+ // getaddrinfo keeps its thread until it returns, even once no attempt waits
87
+ // on it, so a new lookup past the cap would only queue behind the hung ones.
88
+ if (unsettledLookups >= MAX_UNSETTLED_LOOKUPS) {
89
+ throw new LookupBusyError(`DNS lookup for ${host} not started: ${MAX_UNSETTLED_LOOKUPS} earlier lookups have not returned`);
90
+ }
91
+ unsettledLookups++;
92
+ try {
93
+ const results = await lookup(host, { all: true });
94
+ return results.map((r) => r.address);
95
+ }
96
+ finally {
97
+ unsettledLookups--;
98
+ }
70
99
  }
71
100
  /**
72
101
  * 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.
102
+ * the host resolves to is public. Throws SsrfBlockedError otherwise, except
103
+ * that a resolver error with a transient code (a lookup that could not answer
104
+ * this time) is rethrown as is. Returns the resolved IPs.
75
105
  *
76
106
  * NOTE: this checks the host's addresses but does NOT pin them — the caller
77
107
  * fetches by hostname, so undici re-resolves at connect time (a check-time vs
78
108
  * connect-time TOCTOU / short-TTL rebind window). We deliberately do not pin
79
109
  * (CDN IPs rotate). The real anti-SSRF control is the caller-side issuer
80
110
  * 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.
111
+ * fetching, and fetchCimdDocument follows no redirect, so no hop leaves it),
112
+ * so an attacker can't steer the fetch at an arbitrary hostname; this
113
+ * public-range check is defense-in-depth on top of that.
83
114
  */
84
115
  export async function assertPublicHttpsUrl(rawUrl, deps = {}) {
85
116
  let url;
@@ -98,6 +129,11 @@ export async function assertPublicHttpsUrl(rawUrl, deps = {}) {
98
129
  addresses = await resolveAll(url.hostname);
99
130
  }
100
131
  catch (e) {
132
+ // A resolver that could not answer this time says nothing about the host,
133
+ // so the fetch may retry it; a name that does not exist stays a block.
134
+ const code = e?.code;
135
+ if (typeof code === 'string' && TRANSIENT_CODES.has(code))
136
+ throw e;
101
137
  throw new SsrfBlockedError(`DNS resolution failed for ${url.hostname}: ${e.message}`);
102
138
  }
103
139
  if (addresses.length === 0)
@@ -124,7 +160,7 @@ const TRANSIENT_CODES = new Set([
124
160
  'UND_ERR_CONNECT_TIMEOUT',
125
161
  'UND_ERR_SOCKET',
126
162
  ]);
127
- function isTransientFetchError(e) {
163
+ export function isTransientFetchError(e) {
128
164
  if (e instanceof SsrfBlockedError)
129
165
  return false; // deterministic security/shape reject
130
166
  const name = e?.name;
@@ -140,73 +176,76 @@ function isTransientFetchError(e) {
140
176
  return true;
141
177
  return false;
142
178
  }
179
+ // dns.lookup cannot be cancelled, so an attempt stops waiting on it instead.
180
+ function untilAborted(work, signal) {
181
+ return new Promise((resolve, reject) => {
182
+ const onAbort = () => reject(signal.reason);
183
+ signal.addEventListener('abort', onAbort, { once: true });
184
+ void work.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
185
+ });
186
+ }
143
187
  /**
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.
188
+ * SSRF-guarded fetch of a CIMD client-metadata document (JSON): HTTPS and
189
+ * public-IP checked, no redirect followed, size-capped, and every attempt
190
+ * inside one deadline. Returns the parsed JSON object.
147
191
  */
148
192
  export async function fetchCimdDocument(rawUrl, opts = {}) {
149
- const maxBytes = opts.maxBytes ?? 64 * 1024;
193
+ const maxBytes = opts.maxBytes ?? 5120;
150
194
  const timeoutMs = opts.timeoutMs ?? 5000;
151
- const maxRedirects = opts.maxRedirects ?? 3;
152
- const retries = opts.retries ?? 2;
195
+ const deadline = Date.now() + (opts.deadlineMs ?? 8000);
196
+ const retries = opts.retries ?? 1;
153
197
  const backoffMs = opts.retryBackoffMs ?? 200;
154
198
  const doFetch = opts.fetchImpl ?? fetch;
155
199
  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);
200
+ // Retry fast transient failures IN PLACE. Each attempt re-runs the
201
+ // resolve-then-check so a retry can never skip the anti-rebind guard, and
202
+ // the timer is per-attempt (armed across the lookup and the body read, #10).
203
+ for (let attempt = 0;; attempt++) {
204
+ const controller = new AbortController();
205
+ let timedOut = false;
206
+ const timer = setTimeout(() => {
207
+ timedOut = true;
208
+ controller.abort();
209
+ }, Math.max(0, Math.min(timeoutMs, deadline - Date.now())));
210
+ try {
211
+ await untilAborted(assertPublicHttpsUrl(rawUrl, opts.ssrf), controller.signal);
212
+ const res = await doFetch(rawUrl, { redirect: 'manual', signal: controller.signal, headers: { accept: 'application/json' } });
213
+ // CIMD section 5: a redirect is never followed. Only this URL passed the
214
+ // issuer allowlist, and a document behind a redirect could claim its client_id.
215
+ if (res.status >= 300 && res.status < 400)
216
+ throw new SsrfBlockedError('CIMD redirect refused');
217
+ if (!res.ok)
218
+ throw new CimdHttpError(res.status);
219
+ const text = await readCapped(res, maxBytes, controller);
220
+ let parsed;
167
221
  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;
222
+ parsed = JSON.parse(text);
191
223
  }
192
- catch (e) {
193
- if (attempt < retries && isTransientFetchError(e)) {
194
- await sleep(backoffMs * (attempt + 1));
195
- continue;
196
- }
197
- throw e;
224
+ catch {
225
+ throw new SsrfBlockedError('CIMD document is not valid JSON');
198
226
  }
199
- finally {
200
- clearTimeout(timer);
227
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
228
+ throw new SsrfBlockedError('CIMD document is not a JSON object');
229
+ }
230
+ return parsed;
231
+ }
232
+ catch (e) {
233
+ // A refused status throws with the body unread, and only an abort hands
234
+ // that connection back; the controller is this attempt's alone.
235
+ controller.abort();
236
+ const wait = backoffMs * (attempt + 1);
237
+ // A timed-out attempt may leave a lookup hung on the threadpool, ours or
238
+ // fetch's own at connect time, and a retry would start another beside it.
239
+ if (!timedOut && !(e instanceof LookupBusyError) && attempt < retries && isTransientFetchError(e) && deadline - Date.now() > wait) {
240
+ await sleep(wait);
241
+ continue;
201
242
  }
243
+ throw e;
202
244
  }
203
- if (redirectTo !== null) {
204
- current = new URL(redirectTo, current).toString();
205
- continue;
245
+ finally {
246
+ clearTimeout(timer);
206
247
  }
207
- return doc;
208
248
  }
209
- throw new SsrfBlockedError(`too many redirects (> ${maxRedirects})`);
210
249
  }
211
250
  /** Read a response body, aborting the moment it exceeds maxBytes (never buffers
212
251
  * an oversized body). Falls back to a capped arrayBuffer when there is no
package/dist/trim.d.ts CHANGED
@@ -6,6 +6,11 @@ export interface CappedText {
6
6
  }
7
7
  export declare function capText(text: string, maxChars: number, offset?: number): CappedText;
8
8
  export declare function sliceClean(text: string, max: number): string;
9
+ /** An untrusted value made fit for one log line: never throws, at most `max`
10
+ * characters of it (then `...(len=N)`), each unsafe character as a literal
11
+ * `\uXXXX`. Backslash stays as is, so a value already through here comes back
12
+ * unchanged and a clean short value keeps its exact text. */
13
+ export declare function logSafe(value: unknown, max: number): string;
9
14
  /** Largest prefix whose JSON encoding (the wrapping quotes included) fits
10
15
  * `maxEncoded`. Slicing raw characters overshoots whenever the text contains
11
16
  * quotes or backslashes, which JSON.stringify doubles: the escape hatch's
package/dist/trim.js CHANGED
@@ -11,6 +11,26 @@ export function sliceClean(text, max) {
11
11
  const sliced = text.slice(0, max);
12
12
  return /[\uD800-\uDBFF]$/.test(sliced) ? sliced.slice(0, -1) : sliced;
13
13
  }
14
+ // C0, DEL and C1 (NEL, 8-bit CSI and OSC) can end a line or drive a terminal;
15
+ // the line and paragraph separators end a line for many readers; the bidi and
16
+ // invisible format controls make a line read differently from its bytes.
17
+ const LOG_UNSAFE = /[\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2028\u2029\u2066-\u2069]/gu;
18
+ /** An untrusted value made fit for one log line: never throws, at most `max`
19
+ * characters of it (then `...(len=N)`), each unsafe character as a literal
20
+ * `\uXXXX`. Backslash stays as is, so a value already through here comes back
21
+ * unchanged and a clean short value keeps its exact text. */
22
+ export function logSafe(value, max) {
23
+ let text;
24
+ try {
25
+ text = String(value);
26
+ }
27
+ catch {
28
+ return '<unprintable>';
29
+ }
30
+ const cut = text.length > max ? sliceClean(text, max) : text;
31
+ const escaped = cut.replace(LOG_UNSAFE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
32
+ return cut === text ? escaped : `${escaped}...(len=${text.length})`;
33
+ }
14
34
  /** Largest prefix whose JSON encoding (the wrapping quotes included) fits
15
35
  * `maxEncoded`. Slicing raw characters overshoots whenever the text contains
16
36
  * quotes or backslashes, which JSON.stringify doubles: the escape hatch's
@@ -36,7 +36,7 @@ const ARG_KEY_RE = /^[A-Za-z][A-Za-z0-9_.]{0,63}$/;
36
36
  /** Union of every `error:` slug literal emitted anywhere in src/ (kept honest
37
37
  * by a set-equality grep test). Anything outside buckets to `other`. */
38
38
  export const KNOWN_ERROR_SLUGS = new Set([
39
- 'E_ALIAS_EXISTS', 'E_CIMD_INVALID', 'E_ENV_ACCOUNTS_MODE', 'E_MCP_TOKEN_INVALID',
39
+ 'E_ALIAS_EXISTS', 'E_CIMD_INVALID', 'E_CIMD_SSRF_BLOCKED', 'E_ENV_ACCOUNTS_MODE', 'E_MCP_TOKEN_INVALID',
40
40
  'E_NO_DEFAULT_ACCOUNT', 'E_UNKNOWN_BUNDLE', 'E_VALIDATION', 'ambiguous',
41
41
  'api_not_enabled', 'auth_required', 'bad_request', 'binary', 'binary_unsupported',
42
42
  'confirmation_declined', 'diagnose_failed', 'discovery_unavailable', 'dispatch_timeout',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.64",
3
+ "version": "6.0.0-alpha.65",
4
4
  "mcpName": "io.github.bakissation/mcp-google-multi",
5
5
  "description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
6
6
  "type": "module",