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.
- package/dist/arg-normalize.js +4 -3
- package/dist/cimd-cache.d.ts +38 -0
- package/dist/cimd-cache.js +117 -0
- package/dist/http-transport.d.ts +4 -1
- package/dist/http-transport.js +24 -8
- package/dist/mcp-token.d.ts +55 -17
- package/dist/mcp-token.js +377 -97
- package/dist/oauth-as.d.ts +6 -0
- package/dist/oauth-as.js +51 -44
- package/dist/ssrf-guard.d.ts +23 -9
- package/dist/ssrf-guard.js +101 -62
- package/dist/trim.d.ts +5 -0
- package/dist/trim.js +20 -0
- package/dist/usage-metrics.js +1 -1
- package/package.json +1 -1
package/dist/arg-normalize.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/http-transport.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/http-transport.js
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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/mcp-token.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
*
|
|
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
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
|
116
|
-
* lock); the save then drops the spent entries of
|
|
117
|
-
*
|
|
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
|
}
|