mcp-google-multi 6.0.0-beta.1 → 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/README.md +5 -3
- package/dist/accounts.d.ts +22 -1
- package/dist/accounts.js +64 -16
- package/dist/api-probe.d.ts +5 -0
- package/dist/api-probe.js +23 -15
- package/dist/arg-normalize.d.ts +61 -4
- package/dist/arg-normalize.js +229 -4
- package/dist/arg-strict.d.ts +61 -0
- package/dist/arg-strict.js +193 -0
- package/dist/auth.d.ts +4 -3
- package/dist/auth.js +26 -85
- 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 +14 -0
- package/dist/config-file.js +41 -1
- package/dist/discover.js +38 -15
- package/dist/discovery-client.d.ts +11 -1
- package/dist/discovery-client.js +120 -18
- package/dist/doctor.d.ts +15 -3
- package/dist/doctor.js +151 -27
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +23 -1
- package/dist/executor.d.ts +3 -0
- package/dist/executor.js +16 -3
- package/dist/fanout.d.ts +9 -1
- package/dist/fanout.js +83 -14
- 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 +73 -6
- package/dist/http-transport.js +350 -26
- package/dist/identity.d.ts +4 -1
- package/dist/identity.js +25 -6
- package/dist/index.js +142 -114
- package/dist/master-key.js +13 -5
- package/dist/mcp-token.d.ts +136 -19
- package/dist/mcp-token.js +569 -72
- package/dist/metrics-cli.d.ts +21 -0
- package/dist/metrics-cli.js +206 -0
- package/dist/metrics-tap.d.ts +12 -0
- package/dist/metrics-tap.js +95 -0
- 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 +35 -10
- package/dist/oauth-consent.js +101 -39
- package/dist/outbound-allowlist.d.ts +34 -0
- package/dist/outbound-allowlist.js +111 -0
- 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 +66 -2
- package/dist/registry.js +214 -18
- package/dist/scope-catalog.d.ts +11 -0
- package/dist/scope-catalog.js +39 -6
- package/dist/scope-observability.d.ts +8 -6
- package/dist/scope-observability.js +29 -13
- package/dist/services.d.ts +17 -4
- package/dist/services.js +77 -6
- package/dist/setup-prompt.d.ts +1 -1
- package/dist/setup-prompt.js +1 -1
- 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/_coerce.d.ts +5 -0
- package/dist/tools/_coerce.js +11 -0
- package/dist/tools/_errors.d.ts +34 -3
- package/dist/tools/_errors.js +346 -53
- package/dist/tools/_local-files.d.ts +9 -0
- package/dist/tools/_local-files.js +25 -2
- package/dist/tools/account-wizard.d.ts +34 -2
- package/dist/tools/account-wizard.js +223 -65
- 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 +21 -16
- package/dist/tools/analytics.d.ts +27 -0
- package/dist/tools/analytics.js +289 -0
- package/dist/tools/calendar.d.ts +9 -1
- package/dist/tools/calendar.js +93 -29
- package/dist/tools/chat.d.ts +10 -1
- package/dist/tools/chat.js +20 -12
- package/dist/tools/contacts.d.ts +2 -1
- package/dist/tools/contacts.js +74 -43
- package/dist/tools/docs.d.ts +2 -1
- package/dist/tools/docs.js +90 -84
- package/dist/tools/drive.d.ts +2 -1
- package/dist/tools/drive.js +197 -120
- package/dist/tools/forms.d.ts +10 -1
- package/dist/tools/forms.js +26 -19
- package/dist/tools/generated/_shared.d.ts +8 -1
- package/dist/tools/generated/_shared.js +18 -4
- package/dist/tools/generated/admin.d.ts +2 -1
- package/dist/tools/generated/admin.js +610 -479
- package/dist/tools/generated/analytics.d.ts +3 -0
- package/dist/tools/generated/analytics.js +981 -0
- package/dist/tools/generated/appsmarket.d.ts +2 -1
- package/dist/tools/generated/appsmarket.js +9 -9
- package/dist/tools/generated/calendar.d.ts +2 -1
- package/dist/tools/generated/calendar.js +83 -83
- package/dist/tools/generated/chat.d.ts +2 -1
- package/dist/tools/generated/chat.js +178 -151
- package/dist/tools/generated/classroom.d.ts +2 -1
- package/dist/tools/generated/classroom.js +467 -425
- package/dist/tools/generated/cloudidentity.d.ts +2 -1
- package/dist/tools/generated/cloudidentity.js +209 -201
- package/dist/tools/generated/cloudsearch.d.ts +2 -1
- package/dist/tools/generated/cloudsearch.js +139 -138
- package/dist/tools/generated/contacts.d.ts +2 -1
- package/dist/tools/generated/contacts.js +49 -40
- package/dist/tools/generated/drive.d.ts +2 -1
- package/dist/tools/generated/drive.js +133 -124
- 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 +113 -85
- package/dist/tools/generated/forms.d.ts +2 -1
- package/dist/tools/generated/forms.js +14 -14
- package/dist/tools/generated/gmail.d.ts +2 -1
- package/dist/tools/generated/gmail.js +253 -226
- package/dist/tools/generated/groupsmigration.d.ts +2 -1
- package/dist/tools/generated/groupsmigration.js +4 -4
- package/dist/tools/generated/groupssettings.d.ts +2 -1
- package/dist/tools/generated/groupssettings.js +10 -10
- package/dist/tools/generated/index.d.ts +2 -1
- package/dist/tools/generated/index.js +2 -0
- package/dist/tools/generated/keep.d.ts +2 -1
- package/dist/tools/generated/keep.js +23 -22
- package/dist/tools/generated/licensing.d.ts +2 -1
- package/dist/tools/generated/licensing.js +54 -38
- package/dist/tools/generated/meet.d.ts +2 -1
- package/dist/tools/generated/meet.js +41 -41
- package/dist/tools/generated/method-map.d.ts +2 -0
- package/dist/tools/generated/method-map.js +894 -0
- package/dist/tools/generated/postmaster.d.ts +2 -1
- package/dist/tools/generated/postmaster.js +12 -12
- package/dist/tools/generated/reseller.d.ts +2 -1
- package/dist/tools/generated/reseller.js +69 -63
- package/dist/tools/generated/script.d.ts +2 -1
- package/dist/tools/generated/script.js +62 -52
- package/dist/tools/generated/searchconsole.d.ts +2 -1
- package/dist/tools/generated/searchconsole.js +8 -6
- package/dist/tools/generated/sheets.d.ts +2 -1
- package/dist/tools/generated/sheets.js +25 -24
- package/dist/tools/generated/tasks.d.ts +2 -1
- package/dist/tools/generated/tasks.js +15 -9
- package/dist/tools/generated/vault.d.ts +2 -1
- package/dist/tools/generated/vault.js +132 -123
- package/dist/tools/generated/workspaceevents.d.ts +2 -1
- package/dist/tools/generated/workspaceevents.js +48 -47
- package/dist/tools/gmail.d.ts +12 -2
- package/dist/tools/gmail.js +95 -51
- package/dist/tools/google-api.d.ts +2 -3
- package/dist/tools/google-api.js +76 -33
- package/dist/tools/meet.d.ts +10 -1
- package/dist/tools/meet.js +18 -11
- package/dist/tools/searchconsole.d.ts +2 -1
- package/dist/tools/searchconsole.js +27 -23
- package/dist/tools/sheets.d.ts +2 -1
- package/dist/tools/sheets.js +72 -80
- package/dist/tools/slides.d.ts +10 -1
- package/dist/tools/slides.js +23 -16
- package/dist/tools/tasks.d.ts +2 -1
- package/dist/tools/tasks.js +35 -31
- package/dist/trim.d.ts +26 -0
- package/dist/trim.js +67 -0
- package/dist/usage-metrics.d.ts +178 -0
- package/dist/usage-metrics.js +621 -0
- package/dist/write-control.d.ts +27 -1
- package/dist/write-control.js +99 -12
- package/package.json +34 -3
package/dist/http-transport.js
CHANGED
|
@@ -1,15 +1,39 @@
|
|
|
1
|
-
|
|
1
|
+
import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
|
|
2
2
|
// node:http server, the route table, the front guard (Host / Origin / DNS-rebind),
|
|
3
3
|
// and the stateless per-request /mcp dispatch. The OAuth AS endpoints and Bearer
|
|
4
4
|
// verification are a seam filled by B13 (oauth-authorization-server); B12 ships a
|
|
5
5
|
// loopback-owner authenticator so the local-HTTP model works before the AS lands.
|
|
6
6
|
import { createServer } from 'node:http';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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';
|
|
9
11
|
// A hung handler that keeps the connection open would otherwise hold the global
|
|
10
12
|
// serialize() lock forever. Generous by default so slow-but-valid calls (large
|
|
11
13
|
// Drive exports, fan-out) still finish; the point is only to guarantee release.
|
|
12
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
|
+
}
|
|
13
37
|
export function parseOwnerEmails(env = process.env) {
|
|
14
38
|
return (env.MCP_OWNER_EMAILS ?? '')
|
|
15
39
|
.split(',')
|
|
@@ -34,28 +58,100 @@ export function hostAllowed(host, allowed) {
|
|
|
34
58
|
const bare = host.replace(/:\d+$/, '');
|
|
35
59
|
return allowed.includes(host) || allowed.includes(bare);
|
|
36
60
|
}
|
|
37
|
-
|
|
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>`. */
|
|
38
66
|
export function jsonRpcMethod(body) {
|
|
39
|
-
const one = (b) =>
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
}
|
|
43
116
|
}
|
|
44
117
|
export class HttpTransportHost {
|
|
45
118
|
opts;
|
|
46
119
|
httpServer;
|
|
47
|
-
// Serialize the connect→dispatch critical section:
|
|
120
|
+
// Serialize the connect→dispatch critical section PER SERVER: a McpServer
|
|
48
121
|
// captures its transport per request (protocol.js), but the gap between
|
|
49
122
|
// connect() and the dispatch capturing it would still race under true
|
|
50
|
-
// concurrency
|
|
51
|
-
//
|
|
52
|
-
|
|
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;
|
|
53
140
|
constructor(opts) {
|
|
54
141
|
this.opts = opts;
|
|
55
142
|
}
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
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
|
+
});
|
|
59
155
|
return run;
|
|
60
156
|
}
|
|
61
157
|
log(line) {
|
|
@@ -64,7 +160,65 @@ export class HttpTransportHost {
|
|
|
64
160
|
async start() {
|
|
65
161
|
const { config } = this.opts;
|
|
66
162
|
const server = createServer((req, res) => {
|
|
67
|
-
|
|
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
|
+
});
|
|
68
222
|
});
|
|
69
223
|
// Slow-loris mitigation behind the tunnel.
|
|
70
224
|
server.headersTimeout = 15_000;
|
|
@@ -80,15 +234,77 @@ export class HttpTransportHost {
|
|
|
80
234
|
});
|
|
81
235
|
this.log(`listening on ${config.host}:${config.port} (public ${config.publicUrl}, transport ${config.transport})`);
|
|
82
236
|
}
|
|
83
|
-
|
|
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
|
+
}
|
|
84
250
|
const s = this.httpServer;
|
|
85
251
|
if (!s)
|
|
86
252
|
return;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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);
|
|
90
297
|
});
|
|
91
|
-
|
|
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
|
+
}
|
|
92
308
|
}
|
|
93
309
|
/** The bound port (useful when listening on port 0 in tests). */
|
|
94
310
|
address() {
|
|
@@ -123,12 +339,12 @@ export class HttpTransportHost {
|
|
|
123
339
|
frontGuard(req, res) {
|
|
124
340
|
const { allowedHosts, allowedOrigins } = this.opts.config;
|
|
125
341
|
if (!hostAllowed(req.headers.host, allowedHosts)) {
|
|
126
|
-
this.log(`403 host_rejected host=${req.headers.host ?? ''}`);
|
|
342
|
+
this.log(`403 host_rejected host=${logSafe(req.headers.host ?? '', 64)}`);
|
|
127
343
|
this.fail(res, 403, 'host_rejected', 'Host not allowed (DNS-rebinding guard).');
|
|
128
344
|
return false;
|
|
129
345
|
}
|
|
130
346
|
if (!originAllowed(req.headers.origin, allowedOrigins)) {
|
|
131
|
-
this.log(`403 origin_rejected origin=${req.headers.origin ?? ''}`);
|
|
347
|
+
this.log(`403 origin_rejected origin=${logSafe(req.headers.origin ?? '', 64)}`);
|
|
132
348
|
this.fail(res, 403, 'origin_rejected', 'Origin not allowed.');
|
|
133
349
|
return false;
|
|
134
350
|
}
|
|
@@ -141,6 +357,14 @@ export class HttpTransportHost {
|
|
|
141
357
|
}
|
|
142
358
|
if (!this.frontGuard(req, res))
|
|
143
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;
|
|
144
368
|
const auth = await this.opts.authenticate(req);
|
|
145
369
|
if (!auth.ok) {
|
|
146
370
|
for (const [k, v] of Object.entries(auth.headers ?? {}))
|
|
@@ -150,6 +374,78 @@ export class HttpTransportHost {
|
|
|
150
374
|
this.log(`${auth.status} auth_failed path=/mcp`);
|
|
151
375
|
return;
|
|
152
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
|
+
};
|
|
153
449
|
let body;
|
|
154
450
|
try {
|
|
155
451
|
body = await this.readJson(req);
|
|
@@ -157,11 +453,15 @@ export class HttpTransportHost {
|
|
|
157
453
|
catch (e) {
|
|
158
454
|
return this.fail(res, 400, 'invalid_body', e.message);
|
|
159
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
|
+
}
|
|
160
460
|
// Host/Origin are enforced by the front guard above (uniformly for /mcp and
|
|
161
461
|
// the mounted AS routes), so the SDK's own DNS-rebind guard is disabled: its
|
|
162
462
|
// exact-Host match is stricter than the front guard and would 403 valid
|
|
163
463
|
// Hosts (double enforcement, differing rules).
|
|
164
|
-
const transport = new
|
|
464
|
+
const transport = new NodeStreamableHTTPServerTransport({
|
|
165
465
|
sessionIdGenerator: undefined,
|
|
166
466
|
enableJsonResponse: true,
|
|
167
467
|
enableDnsRebindingProtection: false,
|
|
@@ -176,14 +476,28 @@ export class HttpTransportHost {
|
|
|
176
476
|
// releases. (A shared initialized-state persists across stateless requests;
|
|
177
477
|
// benign for the single-owner design.)
|
|
178
478
|
const deadlineMs = this.opts.dispatchTimeoutMs ?? DISPATCH_TIMEOUT_DEFAULT;
|
|
179
|
-
|
|
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
|
+
}
|
|
180
488
|
const disconnected = new Promise((resolve) => res.once('close', () => resolve('closed')));
|
|
181
489
|
let timer;
|
|
182
490
|
const timedOut = new Promise((resolve) => {
|
|
183
491
|
timer = setTimeout(() => resolve('timeout'), deadlineMs);
|
|
184
492
|
timer.unref?.();
|
|
185
493
|
});
|
|
186
|
-
|
|
494
|
+
// Same composition as the stdio leg: the envelope rewrite is innermost,
|
|
495
|
+
// so the tap above it still classifies the original validation prose.
|
|
496
|
+
const enveloped = withValidationEnvelope(transport, validationEnvelope ?? {});
|
|
497
|
+
const tapped = this.opts.metricsTap ? this.opts.metricsTap(enveloped) : enveloped;
|
|
498
|
+
await mcpServer.connect(argShapeFor || strictArgs
|
|
499
|
+
? withArgNormalization(tapped, argShapeFor ?? (() => undefined), this.opts.log, this.opts.onArgRename, strictArgs)
|
|
500
|
+
: tapped);
|
|
187
501
|
// Reflect the dispatch into a non-rejecting arm: if the deadline wins the
|
|
188
502
|
// race, an orphaned handler settling later must not surface as an unhandled
|
|
189
503
|
// rejection — but a genuine dispatch error still propagates (rethrown below).
|
|
@@ -236,6 +550,10 @@ export class HttpTransportHost {
|
|
|
236
550
|
readJson(req) {
|
|
237
551
|
const max = this.opts.maxBodyBytes ?? 4_000_000;
|
|
238
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'));
|
|
239
557
|
let size = 0;
|
|
240
558
|
const chunks = [];
|
|
241
559
|
req.on('data', (c) => {
|
|
@@ -264,6 +582,12 @@ export class HttpTransportHost {
|
|
|
264
582
|
fail(res, status, error, message) {
|
|
265
583
|
if (res.writableEnded)
|
|
266
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
|
+
}
|
|
267
591
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
268
592
|
res.end(JSON.stringify({ error, message }));
|
|
269
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
|
}
|