mcp-google-multi 6.0.0-alpha.63 → 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
  }
@@ -80,40 +80,78 @@ export declare class ReplayGuard {
80
80
  get size(): number;
81
81
  private evictExpired;
82
82
  }
83
+ /** An earlier release's record, keyed by its live token. */
83
84
  export interface RefreshRecord {
84
85
  sub: string;
85
86
  issuedAt: number;
86
87
  family: string;
87
88
  }
88
89
  /**
89
- * Persisted (encrypted under MASTER_KEY) opaque refresh-token store. Rotates on
90
- * every use (a stolen token is usable at most once), and detects REUSE of a
91
- * rotated-away token as theft: the whole token family is revoked (#7 / C14).
92
- * All read-modify-write goes through a file lock so concurrent stdio+HTTP
93
- * processes can't lost-update or double-spend (#12).
90
+ * The family tag of a refresh token this `masterKey`'s store minted, or null
91
+ * for anything else. Derives the key once; the returned function does no I/O,
92
+ * so a host can key a pre-auth rate limit on it.
93
+ */
94
+ export declare function refreshFamilyTagger(masterKey: string): (token: string) => string | null;
95
+ /** Boot check for a refresh store file: returns when it is absent or this
96
+ * release can read it; throws when it does not decrypt under `masterKey`
97
+ * (E_REFRESH_STORE_UNREADABLE) or a newer release wrote it
98
+ * (E_REFRESH_STORE_FORMAT). */
99
+ export declare function assertRefreshStoreReadable(file: string, masterKey: string): void;
100
+ export interface RefreshStoreOptions {
101
+ /** Absolute family lifetime in seconds, counted from the sign-in. Unset: unlimited. */
102
+ maxAgeSec?: number;
103
+ /** Idle lifetime in seconds, counted from the last issue or rotation. Unset: unlimited. */
104
+ idleSec?: number;
105
+ /** Server-side log for a revoked, dropped or expired family. Never given a token or a subject. */
106
+ log?: (line: string) => void;
107
+ }
108
+ /**
109
+ * Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
110
+ * use, and a token carries its family and generation under a MAC, so
111
+ * presenting any generation the family has rotated past is REUSE: the family
112
+ * is revoked for the rest of its life (#7 / C14, H4 F2). The store keeps one
113
+ * record per family, whatever its rotation count. Every read-modify-write goes
114
+ * through a file lock so concurrent stdio+HTTP processes can't lost-update or
115
+ * double-spend (#12). See docs/internals.md.
94
116
  */
95
117
  export declare class RefreshStore {
96
118
  private readonly path;
97
119
  private readonly masterKey;
98
- constructor(path: string, masterKey: string);
120
+ private readonly log;
121
+ private readonly maxAgeMs;
122
+ private readonly idleMs;
123
+ private macKey;
124
+ private legacyEmpty;
125
+ constructor(path: string, masterKey: string, options?: RefreshStoreOptions);
126
+ private get timed();
127
+ private live;
128
+ /** Drop expired sessions in both formats; the count of those dropped. */
129
+ private prune;
130
+ /** A session written while the clock ran ahead would otherwise outlive its
131
+ * limit by the size of the jump. */
132
+ private clamp;
133
+ private key;
134
+ /** With `nowMs` (every issue and rotation), also clamps and prunes. */
99
135
  private load;
100
136
  private save;
101
- issue(nowMs: number, sub: string, family?: string): string;
102
- /** Rotate a presented refresh token; null if unknown OR if the presented
103
- * token was already rotated away (reuse => the family is revoked). The
104
- * record's sub is copied forward and returned so the caller can mint the
105
- * matching access token without trusting anything client-supplied.
137
+ private emit;
138
+ private newFamilyId;
139
+ issue(nowMs: number, sub: string): string;
140
+ /** Rotate a presented refresh token; null if it is unknown, malformed, or
141
+ * rotated away (reuse => the family is revoked). The record's sub is copied
142
+ * forward and returned so the caller can mint the matching access token
143
+ * without trusting anything client-supplied.
106
144
  *
107
145
  * `accept` (optional) is called inside the store lock, before any mutation,
108
- * with the record's sub. false: every active record of that sub is dropped
109
- * and null returned. A throw: nothing is mutated and the error propagates,
110
- * so the presented token stays valid. Absent: unchanged. */
146
+ * with the record's sub. false: every session of that sub is dropped and
147
+ * null returned. A throw: nothing is written and the error propagates, so
148
+ * the presented token stays valid. Absent: unchanged. */
111
149
  rotate(oldToken: string, nowMs: number, accept?: (sub: string) => boolean): {
112
150
  token: string;
113
151
  sub: string;
114
152
  } | null;
115
- /** Drop every ACTIVE record minted under `sub` (linear scan under the store
116
- * lock); the save then drops the spent entries of every family left with no
117
- * active token. Returns the number of active tokens dropped. */
153
+ /** Drop every session minted under `sub`, in both formats (linear scan under
154
+ * the store lock); the save then drops the earlier-release spent entries of
155
+ * the families that died with them. Returns the number of sessions dropped. */
118
156
  purgeTenant(sub: string): number;
119
157
  }