mcp-google-multi 6.0.0-beta.2 → 6.0.0-beta.3
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/accounts.d.ts +17 -1
- package/dist/accounts.js +52 -15
- package/dist/api-probe.d.ts +5 -0
- package/dist/api-probe.js +20 -15
- package/dist/arg-normalize.js +6 -5
- package/dist/arg-strict.d.ts +7 -0
- package/dist/arg-strict.js +34 -4
- package/dist/auth.d.ts +4 -3
- package/dist/auth.js +11 -13
- package/dist/boot-gates.d.ts +26 -0
- package/dist/boot-gates.js +38 -0
- package/dist/cimd-cache.d.ts +38 -0
- package/dist/cimd-cache.js +117 -0
- package/dist/client.d.ts +32 -2
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +11 -0
- package/dist/compose.js +109 -0
- package/dist/config-file.d.ts +13 -0
- package/dist/config-file.js +37 -1
- package/dist/discover.js +3 -1
- package/dist/discovery-client.d.ts +3 -0
- package/dist/discovery-client.js +25 -2
- package/dist/doctor.d.ts +6 -2
- package/dist/doctor.js +119 -23
- package/dist/executor.d.ts +3 -0
- package/dist/executor.js +4 -2
- package/dist/fanout.d.ts +9 -1
- package/dist/fanout.js +44 -10
- package/dist/fs-atomic.d.ts +11 -0
- package/dist/fs-atomic.js +219 -28
- package/dist/hint-provider.d.ts +129 -0
- package/dist/hint-provider.js +64 -0
- package/dist/http-transport.d.ts +62 -4
- package/dist/http-transport.js +343 -24
- package/dist/identity.d.ts +4 -1
- package/dist/identity.js +25 -6
- package/dist/index.js +66 -138
- package/dist/master-key.js +13 -5
- package/dist/mcp-token.d.ts +136 -19
- package/dist/mcp-token.js +569 -72
- package/dist/migrate-config.js +2 -2
- package/dist/oauth-as.d.ts +103 -3
- package/dist/oauth-as.js +374 -79
- package/dist/oauth-consent.d.ts +10 -0
- package/dist/oauth-consent.js +16 -0
- package/dist/outbound-allowlist.js +5 -2
- package/dist/reauth-hint.d.ts +1 -1
- package/dist/reauth-hint.js +12 -8
- package/dist/registry-transfer.d.ts +5 -0
- package/dist/registry-transfer.js +65 -5
- package/dist/registry.d.ts +18 -1
- package/dist/registry.js +71 -15
- package/dist/scope-catalog.d.ts +11 -0
- package/dist/scope-catalog.js +23 -6
- package/dist/scope-observability.d.ts +8 -6
- package/dist/scope-observability.js +29 -13
- package/dist/services.d.ts +15 -10
- package/dist/services.js +59 -15
- package/dist/ssrf-guard.d.ts +23 -9
- package/dist/ssrf-guard.js +101 -62
- package/dist/tenant-purge.d.ts +26 -0
- package/dist/tenant-purge.js +67 -0
- package/dist/token-store.d.ts +20 -2
- package/dist/token-store.js +0 -0
- package/dist/tools/_errors.d.ts +3 -0
- package/dist/tools/_errors.js +53 -24
- package/dist/tools/_local-files.d.ts +9 -0
- package/dist/tools/_local-files.js +18 -0
- package/dist/tools/account-wizard.d.ts +10 -1
- package/dist/tools/account-wizard.js +74 -17
- package/dist/tools/accounts-tool.d.ts +7 -0
- package/dist/tools/accounts-tool.js +34 -18
- package/dist/tools/admin.d.ts +10 -1
- package/dist/tools/admin.js +15 -11
- package/dist/tools/analytics.d.ts +10 -1
- package/dist/tools/analytics.js +13 -8
- package/dist/tools/calendar.d.ts +2 -1
- package/dist/tools/calendar.js +18 -14
- package/dist/tools/chat.d.ts +10 -1
- package/dist/tools/chat.js +13 -8
- package/dist/tools/contacts.d.ts +2 -1
- package/dist/tools/contacts.js +17 -13
- package/dist/tools/docs.d.ts +2 -1
- package/dist/tools/docs.js +34 -30
- package/dist/tools/drive.d.ts +2 -1
- package/dist/tools/drive.js +65 -50
- package/dist/tools/forms.d.ts +10 -1
- package/dist/tools/forms.js +19 -12
- package/dist/tools/generated/_shared.d.ts +2 -1
- package/dist/tools/generated/_shared.js +7 -3
- package/dist/tools/generated/admin.d.ts +2 -1
- package/dist/tools/generated/admin.js +265 -265
- package/dist/tools/generated/analytics.d.ts +2 -1
- package/dist/tools/generated/analytics.js +125 -125
- package/dist/tools/generated/appsmarket.d.ts +2 -1
- package/dist/tools/generated/appsmarket.js +5 -5
- package/dist/tools/generated/calendar.d.ts +2 -1
- package/dist/tools/generated/calendar.js +55 -55
- package/dist/tools/generated/chat.d.ts +2 -1
- package/dist/tools/generated/chat.js +95 -95
- package/dist/tools/generated/classroom.d.ts +2 -1
- package/dist/tools/generated/classroom.js +209 -209
- package/dist/tools/generated/cloudidentity.d.ts +2 -1
- package/dist/tools/generated/cloudidentity.js +133 -133
- package/dist/tools/generated/cloudsearch.d.ts +2 -1
- package/dist/tools/generated/cloudsearch.js +99 -99
- package/dist/tools/generated/contacts.d.ts +2 -1
- package/dist/tools/generated/contacts.js +29 -29
- package/dist/tools/generated/drive.d.ts +2 -1
- package/dist/tools/generated/drive.js +71 -71
- package/dist/tools/generated/driveactivity.d.ts +2 -1
- package/dist/tools/generated/driveactivity.js +3 -3
- package/dist/tools/generated/drivelabels.d.ts +2 -1
- package/dist/tools/generated/drivelabels.js +53 -53
- package/dist/tools/generated/forms.d.ts +2 -1
- package/dist/tools/generated/forms.js +7 -7
- package/dist/tools/generated/gmail.d.ts +2 -1
- package/dist/tools/generated/gmail.js +117 -117
- package/dist/tools/generated/groupsmigration.d.ts +2 -1
- package/dist/tools/generated/groupsmigration.js +3 -3
- package/dist/tools/generated/groupssettings.d.ts +2 -1
- package/dist/tools/generated/groupssettings.js +7 -7
- package/dist/tools/generated/index.d.ts +2 -1
- package/dist/tools/generated/keep.d.ts +2 -1
- package/dist/tools/generated/keep.js +15 -15
- package/dist/tools/generated/licensing.d.ts +2 -1
- package/dist/tools/generated/licensing.js +15 -15
- package/dist/tools/generated/meet.d.ts +2 -1
- package/dist/tools/generated/meet.js +27 -27
- package/dist/tools/generated/postmaster.d.ts +2 -1
- package/dist/tools/generated/postmaster.js +9 -9
- package/dist/tools/generated/reseller.d.ts +2 -1
- package/dist/tools/generated/reseller.js +35 -35
- package/dist/tools/generated/script.d.ts +2 -1
- package/dist/tools/generated/script.js +33 -33
- package/dist/tools/generated/searchconsole.d.ts +2 -1
- package/dist/tools/generated/searchconsole.js +3 -3
- package/dist/tools/generated/sheets.d.ts +2 -1
- package/dist/tools/generated/sheets.js +15 -15
- package/dist/tools/generated/tasks.d.ts +2 -1
- package/dist/tools/generated/tasks.js +5 -5
- package/dist/tools/generated/vault.d.ts +2 -1
- package/dist/tools/generated/vault.js +67 -67
- package/dist/tools/generated/workspaceevents.d.ts +2 -1
- package/dist/tools/generated/workspaceevents.js +31 -31
- package/dist/tools/gmail.d.ts +2 -1
- package/dist/tools/gmail.js +45 -33
- package/dist/tools/google-api.d.ts +2 -3
- package/dist/tools/google-api.js +30 -16
- package/dist/tools/meet.d.ts +10 -1
- package/dist/tools/meet.js +13 -9
- package/dist/tools/searchconsole.d.ts +2 -1
- package/dist/tools/searchconsole.js +17 -13
- package/dist/tools/sheets.d.ts +2 -1
- package/dist/tools/sheets.js +36 -32
- package/dist/tools/slides.d.ts +10 -1
- package/dist/tools/slides.js +17 -10
- package/dist/tools/tasks.d.ts +2 -1
- package/dist/tools/tasks.js +19 -15
- package/dist/trim.d.ts +5 -0
- package/dist/trim.js +20 -0
- package/dist/usage-metrics.d.ts +23 -0
- package/dist/usage-metrics.js +65 -47
- package/dist/write-control.js +10 -2
- package/package.json +27 -1
package/dist/http-transport.js
CHANGED
|
@@ -5,10 +5,35 @@ 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.
|
|
11
14
|
const DISPATCH_TIMEOUT_DEFAULT = 120_000;
|
|
15
|
+
/** MCP 2025-06-18 dropped JSON-RPC batching, and the SDK screens every frame
|
|
16
|
+
* of a batch synchronously in one tick: without a cap one body multiplies the
|
|
17
|
+
* per-call work by however many frames fit in it. */
|
|
18
|
+
const MAX_BATCH_FRAMES = 16;
|
|
19
|
+
// A resolveServer refusal's body reaches the wire verbatim (H3 PR1): validated
|
|
20
|
+
// so a host cannot accidentally leak an operator path/env var through a wrongly
|
|
21
|
+
// shaped slug or an overlong message. No new slug literal enters core itself —
|
|
22
|
+
// the slug is always the host's own value.
|
|
23
|
+
const REFUSAL_SLUG_RE = /^[a-z][a-z0-9_]{0,39}$/;
|
|
24
|
+
const MAX_REFUSAL_MESSAGE_CHARS = 300;
|
|
25
|
+
// C0/C1 controls, format chars (includes bidi overrides like U+202E and
|
|
26
|
+
// zero-width joiners) and the line/paragraph separators; ordinary non-ASCII
|
|
27
|
+
// letters stay allowed.
|
|
28
|
+
const NON_PRINTABLE_RE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
|
29
|
+
function isValidRefusal(r) {
|
|
30
|
+
return (typeof r.error === 'string' &&
|
|
31
|
+
REFUSAL_SLUG_RE.test(r.error) &&
|
|
32
|
+
typeof r.message === 'string' &&
|
|
33
|
+
r.message.length > 0 &&
|
|
34
|
+
r.message.length <= MAX_REFUSAL_MESSAGE_CHARS &&
|
|
35
|
+
!NON_PRINTABLE_RE.test(r.message));
|
|
36
|
+
}
|
|
12
37
|
export function parseOwnerEmails(env = process.env) {
|
|
13
38
|
return (env.MCP_OWNER_EMAILS ?? '')
|
|
14
39
|
.split(',')
|
|
@@ -33,28 +58,100 @@ export function hostAllowed(host, allowed) {
|
|
|
33
58
|
const bare = host.replace(/:\d+$/, '');
|
|
34
59
|
return allowed.includes(host) || allowed.includes(bare);
|
|
35
60
|
}
|
|
36
|
-
|
|
61
|
+
const METHOD_NAME = /^[A-Za-z0-9_./$-]{1,64}$/;
|
|
62
|
+
const BATCH_METHODS_SHOWN = 4;
|
|
63
|
+
/** JSON-RPC method name(s) for an observability log line: no params, no PII.
|
|
64
|
+
* The method is the caller's: anything not shaped like a method name, which
|
|
65
|
+
* includes a value whose String() throws, is logged as `<invalid>`. */
|
|
37
66
|
export function jsonRpcMethod(body) {
|
|
38
|
-
const one = (b) =>
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
67
|
+
const one = (b) => {
|
|
68
|
+
if (!b || typeof b !== 'object' || !('method' in b))
|
|
69
|
+
return undefined;
|
|
70
|
+
const m = b.method;
|
|
71
|
+
return typeof m === 'string' && METHOD_NAME.test(m) ? m : '<invalid>';
|
|
72
|
+
};
|
|
73
|
+
if (!Array.isArray(body))
|
|
74
|
+
return one(body) ?? 'unknown';
|
|
75
|
+
const names = body.map(one).filter((n) => n !== undefined);
|
|
76
|
+
if (names.length === 0)
|
|
77
|
+
return 'batch';
|
|
78
|
+
const rest = names.length - BATCH_METHODS_SHOWN;
|
|
79
|
+
return rest > 0 ? `${names.slice(0, BATCH_METHODS_SHOWN).join(',')},+${rest}` : names.join(',');
|
|
80
|
+
}
|
|
81
|
+
/** setTimeout fires at once for a delay above this, so a longer grace would
|
|
82
|
+
* cut every connection immediately. */
|
|
83
|
+
const MAX_GRACE_MS = 2 ** 31 - 1;
|
|
84
|
+
// A lingering close: a socket destroyed while its client is still sending (a
|
|
85
|
+
// body pipelined after the close) is reset by the kernel, which drops whatever
|
|
86
|
+
// of the last response is still unsent. So the socket only ends its side and
|
|
87
|
+
// waits for the client's FIN, or for the grace to end.
|
|
88
|
+
function endAfterFlush(socket) {
|
|
89
|
+
if (socket.destroyed || socket.writableEnded)
|
|
90
|
+
return;
|
|
91
|
+
// Node arms a keep-alive timer when a response finishes and destroys the
|
|
92
|
+
// socket when it fires, which would cut a slow reader's unread tail just as
|
|
93
|
+
// an early destroy does. Only the client's FIN or the grace ends it now.
|
|
94
|
+
socket.setTimeout(0);
|
|
95
|
+
socket.end();
|
|
96
|
+
}
|
|
97
|
+
// A request that arrives while closing is never answered, so Node keeps it
|
|
98
|
+
// until the socket closes, and a flood pipelined behind a held response would
|
|
99
|
+
// pile up without limit. Reading stops for good instead, which lets TCP flow
|
|
100
|
+
// control stop the client. Node resumes a socket after every parsed request
|
|
101
|
+
// and on drain, so resume becomes a no-op. Such a socket never sees the
|
|
102
|
+
// client's FIN either: the grace ends it.
|
|
103
|
+
function stopReading(socket) {
|
|
104
|
+
socket.pause();
|
|
105
|
+
socket.resume = () => socket;
|
|
106
|
+
}
|
|
107
|
+
// For a log line: the pathname only (a query can carry a code or a state),
|
|
108
|
+
// and never a throw, since the caller is the catch-all.
|
|
109
|
+
function pathOf(req) {
|
|
110
|
+
try {
|
|
111
|
+
return new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return '?';
|
|
115
|
+
}
|
|
42
116
|
}
|
|
43
117
|
export class HttpTransportHost {
|
|
44
118
|
opts;
|
|
45
119
|
httpServer;
|
|
46
|
-
// Serialize the connect→dispatch critical section:
|
|
120
|
+
// Serialize the connect→dispatch critical section PER SERVER: a McpServer
|
|
47
121
|
// captures its transport per request (protocol.js), but the gap between
|
|
48
122
|
// connect() and the dispatch capturing it would still race under true
|
|
49
|
-
// concurrency
|
|
50
|
-
//
|
|
51
|
-
|
|
123
|
+
// concurrency, so all traffic to ONE server runs under one mutex. The free
|
|
124
|
+
// core has one lane (the boot server); with resolveServer each resolved
|
|
125
|
+
// server gets its own, however subjects map onto servers.
|
|
126
|
+
locks = new Map();
|
|
127
|
+
// Requests holding a lane slot, per server; used only with maxQueuedPerLane.
|
|
128
|
+
depth = new Map();
|
|
129
|
+
// What a graceful close waits on: open sockets, per socket the requests it
|
|
130
|
+
// has accepted whose response has not settled and the last of them, and
|
|
131
|
+
// every handle() that has not settled.
|
|
132
|
+
sockets = new Set();
|
|
133
|
+
pending = new Map();
|
|
134
|
+
// Sockets that have accepted a request: nothing pending means the last
|
|
135
|
+
// response finished writing, but its tail can still sit in kernel buffers.
|
|
136
|
+
served = new WeakSet();
|
|
137
|
+
handling = new Set();
|
|
138
|
+
closing = false;
|
|
139
|
+
graceful;
|
|
52
140
|
constructor(opts) {
|
|
53
141
|
this.opts = opts;
|
|
54
142
|
}
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
143
|
+
serializeFor(key, fn) {
|
|
144
|
+
const prev = this.locks.get(key) ?? Promise.resolve();
|
|
145
|
+
const run = prev.then(fn, fn);
|
|
146
|
+
const tail = run.then(() => undefined, () => undefined);
|
|
147
|
+
this.locks.set(key, tail);
|
|
148
|
+
// An idle lane drops out, so the map holds only in-flight subjects rather
|
|
149
|
+
// than every subject ever seen. A request that chained on this tail has
|
|
150
|
+
// already replaced it, so the identity check never frees a live lane.
|
|
151
|
+
void tail.then(() => {
|
|
152
|
+
if (this.locks.get(key) === tail)
|
|
153
|
+
this.locks.delete(key);
|
|
154
|
+
});
|
|
58
155
|
return run;
|
|
59
156
|
}
|
|
60
157
|
log(line) {
|
|
@@ -63,7 +160,65 @@ export class HttpTransportHost {
|
|
|
63
160
|
async start() {
|
|
64
161
|
const { config } = this.opts;
|
|
65
162
|
const server = createServer((req, res) => {
|
|
66
|
-
|
|
163
|
+
const socket = req.socket;
|
|
164
|
+
// Once closing, a connection ends after the last request it accepted
|
|
165
|
+
// before the close, so one arriving now is never handled (RFC 9112 9.6).
|
|
166
|
+
// Its body is left unread: nothing reads the socket again.
|
|
167
|
+
if (this.closing) {
|
|
168
|
+
stopReading(socket);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
this.served.add(socket);
|
|
172
|
+
const entry = this.pending.get(socket);
|
|
173
|
+
if (entry) {
|
|
174
|
+
entry.count++;
|
|
175
|
+
entry.last = res;
|
|
176
|
+
}
|
|
177
|
+
else
|
|
178
|
+
this.pending.set(socket, { count: 1, last: res });
|
|
179
|
+
let settled = false;
|
|
180
|
+
const settle = () => {
|
|
181
|
+
if (settled)
|
|
182
|
+
return;
|
|
183
|
+
settled = true;
|
|
184
|
+
const e = this.pending.get(socket);
|
|
185
|
+
if (!e || --e.count > 0)
|
|
186
|
+
return;
|
|
187
|
+
this.pending.delete(socket);
|
|
188
|
+
if (this.closing)
|
|
189
|
+
endAfterFlush(socket);
|
|
190
|
+
};
|
|
191
|
+
res.once('finish', settle);
|
|
192
|
+
res.once('close', settle);
|
|
193
|
+
// Never rejects: close() waits on it, and a catch-all that itself threw
|
|
194
|
+
// must neither reject close() nor surface as an unhandled rejection.
|
|
195
|
+
// The thrown message can name server paths (a lock timeout does), so it
|
|
196
|
+
// goes only to the host's log.
|
|
197
|
+
const done = this.handle(req, res)
|
|
198
|
+
.catch((e) => {
|
|
199
|
+
try {
|
|
200
|
+
this.fail(res, 500, 'internal_error', 'internal error');
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
// A throwing log must not reach the destroy below: the answer is sent.
|
|
204
|
+
try {
|
|
205
|
+
this.log(`500 internal_error path=${pathOf(req)}: ${e instanceof Error ? logSafe(e.message, 200) : 'non-Error throw'}`);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// nowhere left to report it
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
.catch(() => void res.destroy());
|
|
213
|
+
this.handling.add(done);
|
|
214
|
+
void done.finally(() => this.handling.delete(done));
|
|
215
|
+
});
|
|
216
|
+
server.on('connection', (socket) => {
|
|
217
|
+
this.sockets.add(socket);
|
|
218
|
+
socket.once('close', () => {
|
|
219
|
+
this.sockets.delete(socket);
|
|
220
|
+
this.pending.delete(socket);
|
|
221
|
+
});
|
|
67
222
|
});
|
|
68
223
|
// Slow-loris mitigation behind the tunnel.
|
|
69
224
|
server.headersTimeout = 15_000;
|
|
@@ -79,15 +234,77 @@ export class HttpTransportHost {
|
|
|
79
234
|
});
|
|
80
235
|
this.log(`listening on ${config.host}:${config.port} (public ${config.publicUrl}, transport ${config.transport})`);
|
|
81
236
|
}
|
|
82
|
-
|
|
237
|
+
/** Without graceMs, every connection is cut at once, even during a graceful
|
|
238
|
+
* close. With it, the host stops accepting, lets in-flight requests finish
|
|
239
|
+
* and their responses finish writing, then resolves once each client has
|
|
240
|
+
* closed its side; whatever is still open after graceMs is cut. A repeat
|
|
241
|
+
* call with graceMs during a graceful close joins it, keeping the first
|
|
242
|
+
* grace. graceMs must be a number from 0 to 2^31-1 ms (about 24.8 days, the
|
|
243
|
+
* longest delay a timer holds); anything else, Infinity included, rejects
|
|
244
|
+
* with a TypeError and leaves the host as it was. */
|
|
245
|
+
async close(opts = {}) {
|
|
246
|
+
const { graceMs } = opts;
|
|
247
|
+
if (graceMs !== undefined && !(typeof graceMs === 'number' && graceMs >= 0 && graceMs <= MAX_GRACE_MS)) {
|
|
248
|
+
throw new TypeError(`graceMs must be a number from 0 to ${MAX_GRACE_MS}`);
|
|
249
|
+
}
|
|
83
250
|
const s = this.httpServer;
|
|
84
251
|
if (!s)
|
|
85
252
|
return;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
253
|
+
if (graceMs === undefined) {
|
|
254
|
+
await new Promise((resolve) => {
|
|
255
|
+
s.close(() => resolve());
|
|
256
|
+
s.closeAllConnections?.();
|
|
257
|
+
});
|
|
258
|
+
this.httpServer = undefined;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// Starting over would arm a second grace, and a shorter one would cut
|
|
262
|
+
// what the first still lets finish.
|
|
263
|
+
this.graceful ??= this.closeGracefully(s, graceMs).finally(() => (this.graceful = undefined));
|
|
264
|
+
return this.graceful;
|
|
265
|
+
}
|
|
266
|
+
async closeGracefully(s, graceMs) {
|
|
267
|
+
this.closing = true;
|
|
268
|
+
// Node's close() also destroys every connection it deems idle, and that
|
|
269
|
+
// includes one whose response has ended but is still flushing: a large
|
|
270
|
+
// body would be cut short. Idle sockets are closed below instead.
|
|
271
|
+
const nodeIdleCloser = s.closeIdleConnections;
|
|
272
|
+
s.closeIdleConnections = () => undefined;
|
|
273
|
+
const closed = new Promise((resolve) => s.close(() => resolve()));
|
|
274
|
+
s.closeIdleConnections = nodeIdleCloser;
|
|
275
|
+
// Every request accepted before now is answered, and a connection ends
|
|
276
|
+
// after the last of them: that one says so if it has not started, and the
|
|
277
|
+
// settle hook in start() ends the socket once it finishes. Node itself
|
|
278
|
+
// destroys a socket once a Connection: close response is flushed, which
|
|
279
|
+
// resets it just as endAfterFlush explains, so that end lingers too. A
|
|
280
|
+
// socket with nothing pending may still be delivering the tail of its
|
|
281
|
+
// last response, so only one that never accepted a request is destroyed.
|
|
282
|
+
for (const socket of this.sockets) {
|
|
283
|
+
const entry = this.pending.get(socket);
|
|
284
|
+
if (!entry && !this.served.has(socket)) {
|
|
285
|
+
socket.destroy();
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
socket.destroySoon = () => endAfterFlush(socket);
|
|
289
|
+
if (!entry)
|
|
290
|
+
endAfterFlush(socket);
|
|
291
|
+
else if (!entry.last.headersSent)
|
|
292
|
+
entry.last.setHeader('Connection', 'close');
|
|
293
|
+
}
|
|
294
|
+
let timer;
|
|
295
|
+
const expired = new Promise((resolve) => {
|
|
296
|
+
timer = setTimeout(resolve, graceMs);
|
|
89
297
|
});
|
|
90
|
-
|
|
298
|
+
try {
|
|
299
|
+
await Promise.race([closed.then(() => Promise.all(this.handling)), expired]);
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
s.closeAllConnections();
|
|
304
|
+
await closed;
|
|
305
|
+
this.httpServer = undefined;
|
|
306
|
+
this.closing = false;
|
|
307
|
+
}
|
|
91
308
|
}
|
|
92
309
|
/** The bound port (useful when listening on port 0 in tests). */
|
|
93
310
|
address() {
|
|
@@ -122,12 +339,12 @@ export class HttpTransportHost {
|
|
|
122
339
|
frontGuard(req, res) {
|
|
123
340
|
const { allowedHosts, allowedOrigins } = this.opts.config;
|
|
124
341
|
if (!hostAllowed(req.headers.host, allowedHosts)) {
|
|
125
|
-
this.log(`403 host_rejected host=${req.headers.host ?? ''}`);
|
|
342
|
+
this.log(`403 host_rejected host=${logSafe(req.headers.host ?? '', 64)}`);
|
|
126
343
|
this.fail(res, 403, 'host_rejected', 'Host not allowed (DNS-rebinding guard).');
|
|
127
344
|
return false;
|
|
128
345
|
}
|
|
129
346
|
if (!originAllowed(req.headers.origin, allowedOrigins)) {
|
|
130
|
-
this.log(`403 origin_rejected origin=${req.headers.origin ?? ''}`);
|
|
347
|
+
this.log(`403 origin_rejected origin=${logSafe(req.headers.origin ?? '', 64)}`);
|
|
131
348
|
this.fail(res, 403, 'origin_rejected', 'Origin not allowed.');
|
|
132
349
|
return false;
|
|
133
350
|
}
|
|
@@ -140,6 +357,14 @@ export class HttpTransportHost {
|
|
|
140
357
|
}
|
|
141
358
|
if (!this.frontGuard(req, res))
|
|
142
359
|
return;
|
|
360
|
+
// Registered before anything awaits: a client that leaves while its request
|
|
361
|
+
// waits on the lane must not have it dispatched once its turn comes. A
|
|
362
|
+
// response queued behind another on its connection never emits 'close', so
|
|
363
|
+
// the socket is checked too.
|
|
364
|
+
let gone = false;
|
|
365
|
+
res.once('close', () => (gone = true));
|
|
366
|
+
const socket = req.socket;
|
|
367
|
+
const clientGone = () => gone || socket.destroyed;
|
|
143
368
|
const auth = await this.opts.authenticate(req);
|
|
144
369
|
if (!auth.ok) {
|
|
145
370
|
for (const [k, v] of Object.entries(auth.headers ?? {}))
|
|
@@ -149,6 +374,78 @@ export class HttpTransportHost {
|
|
|
149
374
|
this.log(`${auth.status} auth_failed path=/mcp`);
|
|
150
375
|
return;
|
|
151
376
|
}
|
|
377
|
+
const sub = auth.sub ?? 'owner';
|
|
378
|
+
// Tenant resolution (the seam EE fills): map the verified subject to ITS
|
|
379
|
+
// server. Absent resolver = the one boot server for every subject.
|
|
380
|
+
let target = {
|
|
381
|
+
server: this.opts.server,
|
|
382
|
+
argShapeFor: this.opts.argShapeFor,
|
|
383
|
+
strictArgs: this.opts.strictArgs,
|
|
384
|
+
validationEnvelope: this.opts.validationEnvelope,
|
|
385
|
+
};
|
|
386
|
+
if (this.opts.resolveServer) {
|
|
387
|
+
let resolved;
|
|
388
|
+
try {
|
|
389
|
+
resolved = await this.opts.resolveServer({ sub });
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
resolved = null;
|
|
393
|
+
}
|
|
394
|
+
if (resolved && 'refused' in resolved) {
|
|
395
|
+
const { refused } = resolved;
|
|
396
|
+
if (isValidRefusal(refused)) {
|
|
397
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
398
|
+
res.end(JSON.stringify({ error: refused.error, message: refused.message }));
|
|
399
|
+
this.log(`403 ${refused.error} path=/mcp`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
// An invalid shape falls through to today's exact answer rather than
|
|
403
|
+
// forwarding something unvalidated; a refusal never reaches dispatch
|
|
404
|
+
// either way.
|
|
405
|
+
resolved = null;
|
|
406
|
+
}
|
|
407
|
+
target = resolved;
|
|
408
|
+
if (!target) {
|
|
409
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
410
|
+
res.end(JSON.stringify({ error: 'tenant_not_found', message: 'no tenant is provisioned for this subject' }));
|
|
411
|
+
this.log('403 tenant_not_found path=/mcp');
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// Checked before the body read, so a refused request never buffers its body.
|
|
416
|
+
const cap = this.opts.maxQueuedPerLane;
|
|
417
|
+
if (cap === undefined)
|
|
418
|
+
return this.admitted(req, res, target, sub, clientGone);
|
|
419
|
+
const lane = target.server;
|
|
420
|
+
const held = this.depth.get(lane) ?? 0;
|
|
421
|
+
if (held >= cap) {
|
|
422
|
+
res.setHeader('Retry-After', '1');
|
|
423
|
+
this.fail(res, 429, 'lane_busy', 'too many requests are already queued for this server');
|
|
424
|
+
this.log('429 lane_busy path=/mcp');
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
this.depth.set(lane, held + 1);
|
|
428
|
+
try {
|
|
429
|
+
await this.admitted(req, res, target, sub, clientGone);
|
|
430
|
+
}
|
|
431
|
+
finally {
|
|
432
|
+
const left = (this.depth.get(lane) ?? 1) - 1;
|
|
433
|
+
if (left > 0)
|
|
434
|
+
this.depth.set(lane, left);
|
|
435
|
+
else
|
|
436
|
+
this.depth.delete(lane);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async admitted(req, res, target, sub, clientGone) {
|
|
440
|
+
// Thread the verified subject to tool handlers: the Node transport forwards
|
|
441
|
+
// req.auth verbatim as ctx.http.authInfo. Token/clientId stay empty — the
|
|
442
|
+
// bearer value must not re-enter the dispatch path via handler context.
|
|
443
|
+
req.auth = {
|
|
444
|
+
token: '',
|
|
445
|
+
clientId: '',
|
|
446
|
+
scopes: ['mcp:use'],
|
|
447
|
+
extra: { sub },
|
|
448
|
+
};
|
|
152
449
|
let body;
|
|
153
450
|
try {
|
|
154
451
|
body = await this.readJson(req);
|
|
@@ -156,6 +453,10 @@ export class HttpTransportHost {
|
|
|
156
453
|
catch (e) {
|
|
157
454
|
return this.fail(res, 400, 'invalid_body', e.message);
|
|
158
455
|
}
|
|
456
|
+
const maxFrames = this.opts.maxBatchFrames ?? MAX_BATCH_FRAMES;
|
|
457
|
+
if (Array.isArray(body) && body.length > maxFrames) {
|
|
458
|
+
return this.fail(res, 400, 'batch_too_large', `a JSON-RPC batch may hold at most ${maxFrames} messages`);
|
|
459
|
+
}
|
|
159
460
|
// Host/Origin are enforced by the front guard above (uniformly for /mcp and
|
|
160
461
|
// the mounted AS routes), so the SDK's own DNS-rebind guard is disabled: its
|
|
161
462
|
// exact-Host match is stricter than the front guard and would 403 valid
|
|
@@ -175,7 +476,15 @@ export class HttpTransportHost {
|
|
|
175
476
|
// releases. (A shared initialized-state persists across stateless requests;
|
|
176
477
|
// benign for the single-owner design.)
|
|
177
478
|
const deadlineMs = this.opts.dispatchTimeoutMs ?? DISPATCH_TIMEOUT_DEFAULT;
|
|
178
|
-
|
|
479
|
+
// The lock lane is the SERVER identity, never the subject: two subjects
|
|
480
|
+
// reaching one server (the boot server, or a resolver mapping several
|
|
481
|
+
// subjects to one) would otherwise race it through the connect gap.
|
|
482
|
+
const { server: mcpServer, argShapeFor, strictArgs, validationEnvelope } = target;
|
|
483
|
+
await this.serializeFor(mcpServer, async () => {
|
|
484
|
+
if (clientGone()) {
|
|
485
|
+
this.log('499 client_gone path=/mcp');
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
179
488
|
const disconnected = new Promise((resolve) => res.once('close', () => resolve('closed')));
|
|
180
489
|
let timer;
|
|
181
490
|
const timedOut = new Promise((resolve) => {
|
|
@@ -184,10 +493,10 @@ export class HttpTransportHost {
|
|
|
184
493
|
});
|
|
185
494
|
// Same composition as the stdio leg: the envelope rewrite is innermost,
|
|
186
495
|
// so the tap above it still classifies the original validation prose.
|
|
187
|
-
const enveloped = withValidationEnvelope(transport,
|
|
496
|
+
const enveloped = withValidationEnvelope(transport, validationEnvelope ?? {});
|
|
188
497
|
const tapped = this.opts.metricsTap ? this.opts.metricsTap(enveloped) : enveloped;
|
|
189
|
-
await
|
|
190
|
-
? withArgNormalization(tapped,
|
|
498
|
+
await mcpServer.connect(argShapeFor || strictArgs
|
|
499
|
+
? withArgNormalization(tapped, argShapeFor ?? (() => undefined), this.opts.log, this.opts.onArgRename, strictArgs)
|
|
191
500
|
: tapped);
|
|
192
501
|
// Reflect the dispatch into a non-rejecting arm: if the deadline wins the
|
|
193
502
|
// race, an orphaned handler settling later must not surface as an unhandled
|
|
@@ -241,6 +550,10 @@ export class HttpTransportHost {
|
|
|
241
550
|
readJson(req) {
|
|
242
551
|
const max = this.opts.maxBodyBytes ?? 4_000_000;
|
|
243
552
|
return new Promise((resolve, reject) => {
|
|
553
|
+
// A client that left while the request was authenticated or resolved has
|
|
554
|
+
// already destroyed it, and no 'end' or 'error' would ever follow.
|
|
555
|
+
if (req.destroyed)
|
|
556
|
+
return reject(new Error('request aborted'));
|
|
244
557
|
let size = 0;
|
|
245
558
|
const chunks = [];
|
|
246
559
|
req.on('data', (c) => {
|
|
@@ -269,6 +582,12 @@ export class HttpTransportHost {
|
|
|
269
582
|
fail(res, status, error, message) {
|
|
270
583
|
if (res.writableEnded)
|
|
271
584
|
return;
|
|
585
|
+
// A response already started cannot become an error one; cutting it tells
|
|
586
|
+
// the client it is incomplete.
|
|
587
|
+
if (res.headersSent) {
|
|
588
|
+
res.destroy();
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
272
591
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
273
592
|
res.end(JSON.stringify({ error, message }));
|
|
274
593
|
}
|
package/dist/identity.d.ts
CHANGED
|
@@ -6,9 +6,11 @@ import { type Policy, type Transport } from './write-control.js';
|
|
|
6
6
|
* The one forward-compat seam (frozen public API): the free core builds
|
|
7
7
|
* exactly one context with subject "owner"; EE later instantiates N contexts
|
|
8
8
|
* from N registries. Nothing here may assume a global current-account.
|
|
9
|
+
* `subject` is a string (not the literal 'owner') so a context can carry a
|
|
10
|
+
* tenant id; the free core still always builds 'owner'.
|
|
9
11
|
*/
|
|
10
12
|
export interface IdentityContext {
|
|
11
|
-
subject:
|
|
13
|
+
subject: string;
|
|
12
14
|
accounts: AccountSet;
|
|
13
15
|
policy: Policy;
|
|
14
16
|
getClient: typeof getClient;
|
|
@@ -19,6 +21,7 @@ export interface IdentityContext {
|
|
|
19
21
|
hasToken: typeof hasToken;
|
|
20
22
|
};
|
|
21
23
|
}
|
|
24
|
+
export declare function isOwnerContext(ctx: object | undefined): boolean;
|
|
22
25
|
export declare function buildIdentityContext(env?: NodeJS.ProcessEnv, opts?: {
|
|
23
26
|
transport?: Transport;
|
|
24
27
|
}): IdentityContext;
|
package/dist/identity.js
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
import { getAccountSet } from './accounts.js';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { getAccountSet, getTokenDir, refreshAccountSetIfStale } from './accounts.js';
|
|
2
|
+
import { makeGetClient } from './client.js';
|
|
3
|
+
import { createTokenStore } from './token-store.js';
|
|
4
4
|
import { resolvePolicy } from './write-control.js';
|
|
5
|
+
// Only buildIdentityContext mints the owner brand. The subject string alone
|
|
6
|
+
// never unlocks the owner-only surfaces (account wizard, host-file tools, the
|
|
7
|
+
// operator diagnose report): a hand-built context claiming 'owner' stays a
|
|
8
|
+
// non-owner context.
|
|
9
|
+
const ownerContexts = new WeakSet();
|
|
10
|
+
export function isOwnerContext(ctx) {
|
|
11
|
+
return ctx !== undefined && ownerContexts.has(ctx);
|
|
12
|
+
}
|
|
5
13
|
export function buildIdentityContext(env = process.env, opts = {}) {
|
|
6
|
-
|
|
14
|
+
// Real closures over the single-owner state (the SAME factories EE
|
|
15
|
+
// instantiates per tenant), bound here to the global registry + token dir —
|
|
16
|
+
// behavior-identical to the module-level functions for the free core.
|
|
17
|
+
const tokenStore = createTokenStore(getTokenDir());
|
|
18
|
+
const ctx = {
|
|
7
19
|
subject: 'owner',
|
|
8
20
|
// Live getter: the seam must always see the current registry, never a
|
|
9
21
|
// snapshot pinned from before a wizard mutation or cross-process reload.
|
|
@@ -13,7 +25,14 @@ export function buildIdentityContext(env = process.env, opts = {}) {
|
|
|
13
25
|
// The dispatch transport rides into the resolved policy as a reserved seam
|
|
14
26
|
// (cc-write-control B14); it does not change any write-control verdict.
|
|
15
27
|
policy: resolvePolicy(env, { transport: opts.transport }),
|
|
16
|
-
getClient,
|
|
17
|
-
|
|
28
|
+
getClient: makeGetClient('owner', {
|
|
29
|
+
accounts: getAccountSet,
|
|
30
|
+
readToken: tokenStore.readToken,
|
|
31
|
+
updateToken: tokenStore.updateToken,
|
|
32
|
+
refreshIfStale: refreshAccountSetIfStale,
|
|
33
|
+
}),
|
|
34
|
+
tokenStore,
|
|
18
35
|
};
|
|
36
|
+
ownerContexts.add(ctx);
|
|
37
|
+
return ctx;
|
|
19
38
|
}
|