mcp-google-multi 6.0.0-beta.2 → 6.0.0-beta.4
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 -5
- package/dist/accounts.d.ts +20 -4
- package/dist/accounts.js +68 -44
- package/dist/api-probe.d.ts +5 -0
- package/dist/api-probe.js +20 -15
- package/dist/arg-normalize.js +19 -38
- package/dist/arg-strict.d.ts +7 -0
- package/dist/arg-strict.js +38 -28
- package/dist/auth.d.ts +5 -4
- package/dist/auth.js +16 -23
- package/dist/boot-gates.d.ts +26 -0
- package/dist/boot-gates.js +36 -0
- package/dist/cimd-cache.d.ts +38 -0
- package/dist/cimd-cache.js +111 -0
- package/dist/client-config.d.ts +2 -2
- package/dist/client-config.js +8 -14
- package/dist/client.d.ts +32 -2
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +18 -0
- package/dist/compose.js +109 -0
- package/dist/config-file.d.ts +13 -0
- package/dist/config-file.js +41 -10
- package/dist/discover.js +7 -10
- package/dist/discovery-client.d.ts +3 -0
- package/dist/discovery-client.js +36 -32
- package/dist/doctor.d.ts +9 -5
- package/dist/doctor.js +119 -29
- package/dist/env-load.js +2 -5
- package/dist/executor.d.ts +3 -0
- package/dist/executor.js +6 -6
- package/dist/fanout.d.ts +9 -1
- package/dist/fanout.js +47 -22
- package/dist/fs-atomic.d.ts +8 -0
- package/dist/fs-atomic.js +216 -29
- package/dist/hint-provider.d.ts +129 -0
- package/dist/hint-provider.js +58 -0
- package/dist/http-config.d.ts +2 -2
- package/dist/http-config.js +3 -7
- package/dist/http-transport.d.ts +67 -9
- package/dist/http-transport.js +318 -52
- package/dist/identity.d.ts +6 -3
- package/dist/identity.js +24 -8
- package/dist/index.js +76 -160
- package/dist/master-key.d.ts +3 -3
- package/dist/master-key.js +16 -19
- package/dist/mcp-token.d.ts +126 -19
- package/dist/mcp-token.js +557 -76
- package/dist/metrics-cli.d.ts +1 -1
- package/dist/metrics-cli.js +1 -1
- package/dist/metrics-tap.js +2 -5
- package/dist/migrate-config.d.ts +1 -2
- package/dist/migrate-config.js +5 -7
- package/dist/net-tuning.js +2 -4
- package/dist/oauth-as.d.ts +104 -4
- package/dist/oauth-as.js +368 -114
- package/dist/oauth-consent.d.ts +10 -0
- package/dist/oauth-consent.js +20 -10
- package/dist/outbound-allowlist.js +9 -11
- package/dist/reauth-hint.d.ts +1 -1
- package/dist/reauth-hint.js +11 -10
- package/dist/registry-transfer.d.ts +5 -0
- package/dist/registry-transfer.js +63 -11
- package/dist/registry.d.ts +13 -4
- package/dist/registry.js +100 -72
- package/dist/scope-catalog.d.ts +16 -0
- package/dist/scope-catalog.js +40 -13
- package/dist/scope-observability.d.ts +10 -9
- package/dist/scope-observability.js +34 -24
- package/dist/services.d.ts +18 -10
- package/dist/services.js +61 -22
- package/dist/ssrf-guard.d.ts +23 -9
- package/dist/ssrf-guard.js +106 -78
- package/dist/tenant-purge.d.ts +26 -0
- package/dist/tenant-purge.js +65 -0
- package/dist/token-store.d.ts +20 -2
- package/dist/token-store.js +0 -0
- package/dist/tools/_coerce.d.ts +4 -4
- package/dist/tools/_errors.d.ts +3 -0
- package/dist/tools/_errors.js +72 -60
- package/dist/tools/_local-files.d.ts +9 -0
- package/dist/tools/_local-files.js +22 -8
- package/dist/tools/account-wizard.d.ts +13 -3
- package/dist/tools/account-wizard.js +107 -56
- package/dist/tools/accounts-tool.d.ts +8 -1
- package/dist/tools/accounts-tool.js +35 -19
- package/dist/tools/admin.d.ts +10 -1
- package/dist/tools/admin.js +14 -11
- package/dist/tools/analytics.d.ts +10 -1
- package/dist/tools/analytics.js +14 -10
- package/dist/tools/calendar.d.ts +2 -1
- package/dist/tools/calendar.js +19 -18
- package/dist/tools/chat.d.ts +10 -1
- package/dist/tools/chat.js +12 -8
- package/dist/tools/contacts.d.ts +2 -1
- package/dist/tools/contacts.js +22 -19
- package/dist/tools/docs.d.ts +2 -1
- package/dist/tools/docs.js +35 -36
- package/dist/tools/drive.d.ts +2 -1
- package/dist/tools/drive.js +80 -74
- package/dist/tools/forms.d.ts +10 -1
- package/dist/tools/forms.js +18 -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-mime.d.ts +1 -1
- package/dist/tools/gmail-mime.js +7 -12
- package/dist/tools/gmail.d.ts +4 -3
- package/dist/tools/gmail.js +67 -69
- package/dist/tools/google-api.d.ts +2 -3
- package/dist/tools/google-api.js +34 -22
- package/dist/tools/meet.d.ts +10 -1
- package/dist/tools/meet.js +12 -9
- package/dist/tools/searchconsole.d.ts +2 -1
- package/dist/tools/searchconsole.js +16 -13
- package/dist/tools/sheets.d.ts +2 -1
- package/dist/tools/sheets.js +35 -32
- package/dist/tools/slides.d.ts +10 -1
- package/dist/tools/slides.js +16 -10
- package/dist/tools/tasks.d.ts +2 -1
- package/dist/tools/tasks.js +18 -15
- package/dist/trim.d.ts +5 -0
- package/dist/trim.js +19 -0
- package/dist/usage-metrics.d.ts +23 -0
- package/dist/usage-metrics.js +67 -56
- package/dist/write-control.d.ts +4 -8
- package/dist/write-control.js +13 -6
- package/package.json +47 -21
package/dist/http-transport.js
CHANGED
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
|
|
2
|
-
// node:http server, the route table, the front guard (Host / Origin / DNS-rebind),
|
|
3
|
-
// and the stateless per-request /mcp dispatch. The OAuth AS endpoints and Bearer
|
|
4
|
-
// verification are a seam filled by B13 (oauth-authorization-server); B12 ships a
|
|
5
|
-
// loopback-owner authenticator so the local-HTTP model works before the AS lands.
|
|
6
2
|
import { createServer } from 'node:http';
|
|
7
3
|
import { withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
|
|
4
|
+
import { logSafe } from './trim.js';
|
|
5
|
+
// A host wrapping the log callbacks it hands core needs this, and trim has no package export.
|
|
6
|
+
export { logSafe } from './trim.js';
|
|
8
7
|
// A hung handler that keeps the connection open would otherwise hold the global
|
|
9
|
-
// serialize() lock forever
|
|
10
|
-
// Drive exports, fan-out) still finish; the point is only to guarantee release.
|
|
8
|
+
// serialize() lock forever; generous by default so slow-but-valid calls still finish.
|
|
11
9
|
const DISPATCH_TIMEOUT_DEFAULT = 120_000;
|
|
10
|
+
// Without a cap, one body multiplies per-call work by however many
|
|
11
|
+
// JSON-RPC frames the SDK's synchronous batch screening finds in it.
|
|
12
|
+
const MAX_BATCH_FRAMES = 16;
|
|
13
|
+
// A refusal's body reaches the wire verbatim, so it's validated: no new slug
|
|
14
|
+
// literal enters core, and an overlong message can't leak an operator path/env var.
|
|
15
|
+
const REFUSAL_SLUG_RE = /^[a-z][a-z0-9_]{0,39}$/;
|
|
16
|
+
const MAX_REFUSAL_MESSAGE_CHARS = 300;
|
|
17
|
+
// C0/C1 controls, format chars (bidi overrides, zero-width joiners) and
|
|
18
|
+
// line/paragraph separators; ordinary non-ASCII letters stay allowed.
|
|
19
|
+
const NON_PRINTABLE_RE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
|
20
|
+
function isValidRefusal(r) {
|
|
21
|
+
return (typeof r.error === 'string' &&
|
|
22
|
+
REFUSAL_SLUG_RE.test(r.error) &&
|
|
23
|
+
typeof r.message === 'string' &&
|
|
24
|
+
r.message.length > 0 &&
|
|
25
|
+
r.message.length <= MAX_REFUSAL_MESSAGE_CHARS &&
|
|
26
|
+
!NON_PRINTABLE_RE.test(r.message));
|
|
27
|
+
}
|
|
12
28
|
export function parseOwnerEmails(env = process.env) {
|
|
13
29
|
return (env.MCP_OWNER_EMAILS ?? '')
|
|
14
30
|
.split(',')
|
|
@@ -17,7 +33,7 @@ export function parseOwnerEmails(env = process.env) {
|
|
|
17
33
|
}
|
|
18
34
|
/** Front guard: an Origin, if present, must be allowlisted; a Host must be
|
|
19
35
|
* allowlisted. Absent Origin passes (native/CLI/backend clients — the claude.ai
|
|
20
|
-
* connector calls /mcp server-to-server with no Origin
|
|
36
|
+
* connector calls /mcp server-to-server with no Origin). */
|
|
21
37
|
export function originAllowed(origin, allowed) {
|
|
22
38
|
if (!origin)
|
|
23
39
|
return true;
|
|
@@ -33,28 +49,85 @@ export function hostAllowed(host, allowed) {
|
|
|
33
49
|
const bare = host.replace(/:\d+$/, '');
|
|
34
50
|
return allowed.includes(host) || allowed.includes(bare);
|
|
35
51
|
}
|
|
36
|
-
|
|
52
|
+
const METHOD_NAME = /^[A-Za-z0-9_./$-]{1,64}$/;
|
|
53
|
+
const BATCH_METHODS_SHOWN = 4;
|
|
54
|
+
/** JSON-RPC method name(s) for an observability log line: no params, no PII.
|
|
55
|
+
* The method is the caller's: anything not shaped like a method name, which
|
|
56
|
+
* includes a value whose String() throws, is logged as `<invalid>`. */
|
|
37
57
|
export function jsonRpcMethod(body) {
|
|
38
|
-
const one = (b) =>
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
58
|
+
const one = (b) => {
|
|
59
|
+
if (!b || typeof b !== 'object' || !('method' in b))
|
|
60
|
+
return undefined;
|
|
61
|
+
const m = b.method;
|
|
62
|
+
return typeof m === 'string' && METHOD_NAME.test(m) ? m : '<invalid>';
|
|
63
|
+
};
|
|
64
|
+
if (!Array.isArray(body))
|
|
65
|
+
return one(body) ?? 'unknown';
|
|
66
|
+
const names = body.map(one).filter((n) => n !== undefined);
|
|
67
|
+
if (names.length === 0)
|
|
68
|
+
return 'batch';
|
|
69
|
+
const rest = names.length - BATCH_METHODS_SHOWN;
|
|
70
|
+
return rest > 0 ? `${names.slice(0, BATCH_METHODS_SHOWN).join(',')},+${rest}` : names.join(',');
|
|
71
|
+
}
|
|
72
|
+
/** setTimeout fires at once for a delay above this, so a longer grace would
|
|
73
|
+
* cut every connection immediately. */
|
|
74
|
+
const MAX_GRACE_MS = 2 ** 31 - 1;
|
|
75
|
+
// A lingering close (ends the socket, never destroys it); see docs/internals.md (Graceful close).
|
|
76
|
+
function endAfterFlush(socket) {
|
|
77
|
+
if (socket.destroyed || socket.writableEnded)
|
|
78
|
+
return;
|
|
79
|
+
// Disarm the keep-alive timer Node arms on finish, or it destroys the
|
|
80
|
+
// socket ~6s later and cuts a slow reader's unread tail the same way an early destroy does.
|
|
81
|
+
socket.setTimeout(0);
|
|
82
|
+
socket.end();
|
|
83
|
+
}
|
|
84
|
+
// Stops reading for good so TCP flow control stops the client, instead of
|
|
85
|
+
// piling up a flood pipelined behind a held response; see docs/internals.md (Graceful close).
|
|
86
|
+
function stopReading(socket) {
|
|
87
|
+
socket.pause();
|
|
88
|
+
socket.resume = () => socket;
|
|
89
|
+
}
|
|
90
|
+
// For a log line: the pathname only (a query can carry a code or a state),
|
|
91
|
+
// and never a throw, since the caller is the catch-all.
|
|
92
|
+
function pathOf(req) {
|
|
93
|
+
try {
|
|
94
|
+
return new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return '?';
|
|
98
|
+
}
|
|
42
99
|
}
|
|
43
100
|
export class HttpTransportHost {
|
|
44
101
|
opts;
|
|
45
102
|
httpServer;
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
|
|
103
|
+
// Per-server dispatch mutex; see docs/internals.md.
|
|
104
|
+
locks = new Map();
|
|
105
|
+
// Requests holding a lane slot, per server; used only with maxQueuedPerLane.
|
|
106
|
+
depth = new Map();
|
|
107
|
+
// What a graceful close waits on: open sockets and every handle() not yet
|
|
108
|
+
// settled; see docs/internals.md (Graceful close).
|
|
109
|
+
sockets = new Set();
|
|
110
|
+
pending = new Map();
|
|
111
|
+
// Sockets that have accepted a request: nothing pending means the last
|
|
112
|
+
// response finished writing, but its tail can still sit in kernel buffers.
|
|
113
|
+
served = new WeakSet();
|
|
114
|
+
handling = new Set();
|
|
115
|
+
closing = false;
|
|
116
|
+
graceful;
|
|
52
117
|
constructor(opts) {
|
|
53
118
|
this.opts = opts;
|
|
54
119
|
}
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
120
|
+
serializeFor(key, fn) {
|
|
121
|
+
const prev = this.locks.get(key) ?? Promise.resolve();
|
|
122
|
+
const run = prev.then(fn, fn);
|
|
123
|
+
const tail = run.then(() => undefined, () => undefined);
|
|
124
|
+
this.locks.set(key, tail);
|
|
125
|
+
// An idle lane drops out of the map; the identity check below skips a
|
|
126
|
+
// later request that already replaced this tail, so it never frees a live lane.
|
|
127
|
+
void tail.then(() => {
|
|
128
|
+
if (this.locks.get(key) === tail)
|
|
129
|
+
this.locks.delete(key);
|
|
130
|
+
});
|
|
58
131
|
return run;
|
|
59
132
|
}
|
|
60
133
|
log(line) {
|
|
@@ -63,7 +136,62 @@ export class HttpTransportHost {
|
|
|
63
136
|
async start() {
|
|
64
137
|
const { config } = this.opts;
|
|
65
138
|
const server = createServer((req, res) => {
|
|
66
|
-
|
|
139
|
+
const socket = req.socket;
|
|
140
|
+
// Once closing, a request arriving now is never handled (RFC 9112 §9.6)
|
|
141
|
+
// and its body is left unread.
|
|
142
|
+
if (this.closing) {
|
|
143
|
+
stopReading(socket);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.served.add(socket);
|
|
147
|
+
const entry = this.pending.get(socket);
|
|
148
|
+
if (entry) {
|
|
149
|
+
entry.count++;
|
|
150
|
+
entry.last = res;
|
|
151
|
+
}
|
|
152
|
+
else
|
|
153
|
+
this.pending.set(socket, { count: 1, last: res });
|
|
154
|
+
let settled = false;
|
|
155
|
+
const settle = () => {
|
|
156
|
+
if (settled)
|
|
157
|
+
return;
|
|
158
|
+
settled = true;
|
|
159
|
+
const e = this.pending.get(socket);
|
|
160
|
+
if (!e || --e.count > 0)
|
|
161
|
+
return;
|
|
162
|
+
this.pending.delete(socket);
|
|
163
|
+
if (this.closing)
|
|
164
|
+
endAfterFlush(socket);
|
|
165
|
+
};
|
|
166
|
+
res.once('finish', settle);
|
|
167
|
+
res.once('close', settle);
|
|
168
|
+
// Never rejects (close() waits on it); the thrown message can name
|
|
169
|
+
// server paths, so it goes only to the host's log.
|
|
170
|
+
const done = this.handle(req, res)
|
|
171
|
+
.catch((e) => {
|
|
172
|
+
try {
|
|
173
|
+
this.fail(res, 500, 'internal_error', 'internal error');
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
// A throwing log must not reach the destroy below: the answer is sent.
|
|
177
|
+
try {
|
|
178
|
+
this.log(`500 internal_error path=${pathOf(req)}: ${e instanceof Error ? logSafe(e.message, 200) : 'non-Error throw'}`);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// nowhere left to report it
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
.catch(() => void res.destroy());
|
|
186
|
+
this.handling.add(done);
|
|
187
|
+
void done.finally(() => this.handling.delete(done));
|
|
188
|
+
});
|
|
189
|
+
server.on('connection', (socket) => {
|
|
190
|
+
this.sockets.add(socket);
|
|
191
|
+
socket.once('close', () => {
|
|
192
|
+
this.sockets.delete(socket);
|
|
193
|
+
this.pending.delete(socket);
|
|
194
|
+
});
|
|
67
195
|
});
|
|
68
196
|
// Slow-loris mitigation behind the tunnel.
|
|
69
197
|
server.headersTimeout = 15_000;
|
|
@@ -79,15 +207,70 @@ export class HttpTransportHost {
|
|
|
79
207
|
});
|
|
80
208
|
this.log(`listening on ${config.host}:${config.port} (public ${config.publicUrl}, transport ${config.transport})`);
|
|
81
209
|
}
|
|
82
|
-
|
|
210
|
+
/** Without graceMs, every connection is cut at once, even during a graceful
|
|
211
|
+
* close. With it, the host stops accepting, lets in-flight requests finish
|
|
212
|
+
* and their responses finish writing, then resolves once each client has
|
|
213
|
+
* closed its side; whatever is still open after graceMs is cut. A repeat
|
|
214
|
+
* call with graceMs during a graceful close joins it, keeping the first
|
|
215
|
+
* grace. graceMs must be a number from 0 to 2^31-1 ms (about 24.8 days, the
|
|
216
|
+
* longest delay a timer holds); anything else, Infinity included, rejects
|
|
217
|
+
* with a TypeError and leaves the host as it was. */
|
|
218
|
+
async close(opts = {}) {
|
|
219
|
+
const { graceMs } = opts;
|
|
220
|
+
if (graceMs !== undefined && !(typeof graceMs === 'number' && graceMs >= 0 && graceMs <= MAX_GRACE_MS)) {
|
|
221
|
+
throw new TypeError(`graceMs must be a number from 0 to ${MAX_GRACE_MS}`);
|
|
222
|
+
}
|
|
83
223
|
const s = this.httpServer;
|
|
84
224
|
if (!s)
|
|
85
225
|
return;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
226
|
+
if (graceMs === undefined) {
|
|
227
|
+
await new Promise((resolve) => {
|
|
228
|
+
s.close(() => resolve());
|
|
229
|
+
s.closeAllConnections?.();
|
|
230
|
+
});
|
|
231
|
+
this.httpServer = undefined;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// Starting over would arm a second grace, and a shorter one would cut
|
|
235
|
+
// what the first still lets finish.
|
|
236
|
+
this.graceful ??= this.closeGracefully(s, graceMs).finally(() => (this.graceful = undefined));
|
|
237
|
+
return this.graceful;
|
|
238
|
+
}
|
|
239
|
+
async closeGracefully(s, graceMs) {
|
|
240
|
+
this.closing = true;
|
|
241
|
+
// Node's close() also destroys idle connections, which would cut short a
|
|
242
|
+
// large response still flushing; idle sockets are closed below instead.
|
|
243
|
+
const nodeIdleCloser = s.closeIdleConnections;
|
|
244
|
+
s.closeIdleConnections = () => undefined;
|
|
245
|
+
const closed = new Promise((resolve) => s.close(() => resolve()));
|
|
246
|
+
s.closeIdleConnections = nodeIdleCloser;
|
|
247
|
+
// Socket bookkeeping below; see docs/internals.md (Graceful close).
|
|
248
|
+
for (const socket of this.sockets) {
|
|
249
|
+
const entry = this.pending.get(socket);
|
|
250
|
+
if (!entry && !this.served.has(socket)) {
|
|
251
|
+
socket.destroy();
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
socket.destroySoon = () => endAfterFlush(socket);
|
|
255
|
+
if (!entry)
|
|
256
|
+
endAfterFlush(socket);
|
|
257
|
+
else if (!entry.last.headersSent)
|
|
258
|
+
entry.last.setHeader('Connection', 'close');
|
|
259
|
+
}
|
|
260
|
+
let timer;
|
|
261
|
+
const expired = new Promise((resolve) => {
|
|
262
|
+
timer = setTimeout(resolve, graceMs);
|
|
89
263
|
});
|
|
90
|
-
|
|
264
|
+
try {
|
|
265
|
+
await Promise.race([closed.then(() => Promise.all(this.handling)), expired]);
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
s.closeAllConnections();
|
|
270
|
+
await closed;
|
|
271
|
+
this.httpServer = undefined;
|
|
272
|
+
this.closing = false;
|
|
273
|
+
}
|
|
91
274
|
}
|
|
92
275
|
/** The bound port (useful when listening on port 0 in tests). */
|
|
93
276
|
address() {
|
|
@@ -122,12 +305,12 @@ export class HttpTransportHost {
|
|
|
122
305
|
frontGuard(req, res) {
|
|
123
306
|
const { allowedHosts, allowedOrigins } = this.opts.config;
|
|
124
307
|
if (!hostAllowed(req.headers.host, allowedHosts)) {
|
|
125
|
-
this.log(`403 host_rejected host=${req.headers.host ?? ''}`);
|
|
308
|
+
this.log(`403 host_rejected host=${logSafe(req.headers.host ?? '', 64)}`);
|
|
126
309
|
this.fail(res, 403, 'host_rejected', 'Host not allowed (DNS-rebinding guard).');
|
|
127
310
|
return false;
|
|
128
311
|
}
|
|
129
312
|
if (!originAllowed(req.headers.origin, allowedOrigins)) {
|
|
130
|
-
this.log(`403 origin_rejected origin=${req.headers.origin ?? ''}`);
|
|
313
|
+
this.log(`403 origin_rejected origin=${logSafe(req.headers.origin ?? '', 64)}`);
|
|
131
314
|
this.fail(res, 403, 'origin_rejected', 'Origin not allowed.');
|
|
132
315
|
return false;
|
|
133
316
|
}
|
|
@@ -140,6 +323,11 @@ export class HttpTransportHost {
|
|
|
140
323
|
}
|
|
141
324
|
if (!this.frontGuard(req, res))
|
|
142
325
|
return;
|
|
326
|
+
// Registered before anything awaits (client-gone skip); see docs/internals.md.
|
|
327
|
+
let gone = false;
|
|
328
|
+
res.once('close', () => (gone = true));
|
|
329
|
+
const socket = req.socket;
|
|
330
|
+
const clientGone = () => gone || socket.destroyed;
|
|
143
331
|
const auth = await this.opts.authenticate(req);
|
|
144
332
|
if (!auth.ok) {
|
|
145
333
|
for (const [k, v] of Object.entries(auth.headers ?? {}))
|
|
@@ -149,6 +337,76 @@ export class HttpTransportHost {
|
|
|
149
337
|
this.log(`${auth.status} auth_failed path=/mcp`);
|
|
150
338
|
return;
|
|
151
339
|
}
|
|
340
|
+
const sub = auth.sub ?? 'owner';
|
|
341
|
+
// Tenant resolution (a multi-tenant host fills this seam): map the
|
|
342
|
+
// verified subject to ITS server. Absent resolver = the one boot server for every subject.
|
|
343
|
+
let target = {
|
|
344
|
+
server: this.opts.server,
|
|
345
|
+
argShapeFor: this.opts.argShapeFor,
|
|
346
|
+
strictArgs: this.opts.strictArgs,
|
|
347
|
+
validationEnvelope: this.opts.validationEnvelope,
|
|
348
|
+
};
|
|
349
|
+
if (this.opts.resolveServer) {
|
|
350
|
+
let resolved;
|
|
351
|
+
try {
|
|
352
|
+
resolved = await this.opts.resolveServer({ sub });
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
resolved = null;
|
|
356
|
+
}
|
|
357
|
+
if (resolved && 'refused' in resolved) {
|
|
358
|
+
const { refused } = resolved;
|
|
359
|
+
if (isValidRefusal(refused)) {
|
|
360
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
361
|
+
res.end(JSON.stringify({ error: refused.error, message: refused.message }));
|
|
362
|
+
this.log(`403 ${refused.error} path=/mcp`);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
// An invalid shape falls through to today's exact answer rather than
|
|
366
|
+
// forwarding something unvalidated.
|
|
367
|
+
resolved = null;
|
|
368
|
+
}
|
|
369
|
+
target = resolved;
|
|
370
|
+
if (!target) {
|
|
371
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
372
|
+
res.end(JSON.stringify({ error: 'tenant_not_found', message: 'no tenant is provisioned for this subject' }));
|
|
373
|
+
this.log('403 tenant_not_found path=/mcp');
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// Checked before the body read, so a refused request never buffers its body.
|
|
378
|
+
const cap = this.opts.maxQueuedPerLane;
|
|
379
|
+
if (cap === undefined)
|
|
380
|
+
return this.admitted(req, res, target, sub, clientGone);
|
|
381
|
+
const lane = target.server;
|
|
382
|
+
const held = this.depth.get(lane) ?? 0;
|
|
383
|
+
if (held >= cap) {
|
|
384
|
+
res.setHeader('Retry-After', '1');
|
|
385
|
+
this.fail(res, 429, 'lane_busy', 'too many requests are already queued for this server');
|
|
386
|
+
this.log('429 lane_busy path=/mcp');
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
this.depth.set(lane, held + 1);
|
|
390
|
+
try {
|
|
391
|
+
await this.admitted(req, res, target, sub, clientGone);
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
const left = (this.depth.get(lane) ?? 1) - 1;
|
|
395
|
+
if (left > 0)
|
|
396
|
+
this.depth.set(lane, left);
|
|
397
|
+
else
|
|
398
|
+
this.depth.delete(lane);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async admitted(req, res, target, sub, clientGone) {
|
|
402
|
+
// Thread the verified subject to tool handlers via ctx.http.authInfo;
|
|
403
|
+
// token/clientId stay empty so the bearer value never re-enters the dispatch path.
|
|
404
|
+
req.auth = {
|
|
405
|
+
token: '',
|
|
406
|
+
clientId: '',
|
|
407
|
+
scopes: ['mcp:use'],
|
|
408
|
+
extra: { sub },
|
|
409
|
+
};
|
|
152
410
|
let body;
|
|
153
411
|
try {
|
|
154
412
|
body = await this.readJson(req);
|
|
@@ -156,42 +414,40 @@ export class HttpTransportHost {
|
|
|
156
414
|
catch (e) {
|
|
157
415
|
return this.fail(res, 400, 'invalid_body', e.message);
|
|
158
416
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
417
|
+
const maxFrames = this.opts.maxBatchFrames ?? MAX_BATCH_FRAMES;
|
|
418
|
+
if (Array.isArray(body) && body.length > maxFrames) {
|
|
419
|
+
return this.fail(res, 400, 'batch_too_large', `a JSON-RPC batch may hold at most ${maxFrames} messages`);
|
|
420
|
+
}
|
|
421
|
+
// The SDK's own DNS-rebind guard is disabled: Host/Origin are already
|
|
422
|
+
// enforced above, and its stricter exact-Host match would 403 valid Hosts.
|
|
163
423
|
const transport = new NodeStreamableHTTPServerTransport({
|
|
164
424
|
sessionIdGenerator: undefined,
|
|
165
425
|
enableJsonResponse: true,
|
|
166
426
|
enableDnsRebindingProtection: false,
|
|
167
427
|
});
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
// can never hit "Already connected". The serialized section is guaranteed to
|
|
171
|
-
// settle three ways — the handler finishes, the client disconnects (res
|
|
172
|
-
// 'close'), or a dispatch deadline fires — so neither a client that holds the
|
|
173
|
-
// connection open nor a hung upstream can wedge the lock. The transport is
|
|
174
|
-
// closed in a finally (which resets server._transport) before the lock
|
|
175
|
-
// releases. (A shared initialized-state persists across stateless requests;
|
|
176
|
-
// benign for the single-owner design.)
|
|
428
|
+
// connect + dispatch + disconnect run inside the per-server mutex, keyed
|
|
429
|
+
// on the server identity, never the subject; see docs/internals.md.
|
|
177
430
|
const deadlineMs = this.opts.dispatchTimeoutMs ?? DISPATCH_TIMEOUT_DEFAULT;
|
|
178
|
-
|
|
431
|
+
const { server: mcpServer, argShapeFor, strictArgs, validationEnvelope } = target;
|
|
432
|
+
await this.serializeFor(mcpServer, async () => {
|
|
433
|
+
if (clientGone()) {
|
|
434
|
+
this.log('499 client_gone path=/mcp');
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
179
437
|
const disconnected = new Promise((resolve) => res.once('close', () => resolve('closed')));
|
|
180
438
|
let timer;
|
|
181
439
|
const timedOut = new Promise((resolve) => {
|
|
182
440
|
timer = setTimeout(() => resolve('timeout'), deadlineMs);
|
|
183
441
|
timer.unref?.();
|
|
184
442
|
});
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
const enveloped = withValidationEnvelope(transport, this.opts.validationEnvelope ?? {});
|
|
443
|
+
// Envelope rewrite innermost, as on stdio; see docs/internals.md (Argument normalization).
|
|
444
|
+
const enveloped = withValidationEnvelope(transport, validationEnvelope ?? {});
|
|
188
445
|
const tapped = this.opts.metricsTap ? this.opts.metricsTap(enveloped) : enveloped;
|
|
189
|
-
await
|
|
190
|
-
? withArgNormalization(tapped,
|
|
446
|
+
await mcpServer.connect(argShapeFor || strictArgs
|
|
447
|
+
? withArgNormalization(tapped, argShapeFor ?? (() => undefined), this.opts.log, this.opts.onArgRename, strictArgs)
|
|
191
448
|
: tapped);
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
// rejection — but a genuine dispatch error still propagates (rethrown below).
|
|
449
|
+
// Non-rejecting arm: an orphaned handler settling after the deadline wins
|
|
450
|
+
// must not surface as an unhandled rejection; a real error is rethrown below.
|
|
195
451
|
let dispatchErr;
|
|
196
452
|
const dispatchArm = transport
|
|
197
453
|
.handleRequest(req, res, body)
|
|
@@ -229,7 +485,7 @@ export class HttpTransportHost {
|
|
|
229
485
|
health(res) {
|
|
230
486
|
const { config, ownerConfigured, version } = this.opts;
|
|
231
487
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
232
|
-
// No secrets, no owner emails
|
|
488
|
+
// No secrets, no owner emails.
|
|
233
489
|
res.end(JSON.stringify({
|
|
234
490
|
status: 'ok',
|
|
235
491
|
transport: config.transport,
|
|
@@ -241,6 +497,10 @@ export class HttpTransportHost {
|
|
|
241
497
|
readJson(req) {
|
|
242
498
|
const max = this.opts.maxBodyBytes ?? 4_000_000;
|
|
243
499
|
return new Promise((resolve, reject) => {
|
|
500
|
+
// A client that left while the request was authenticated or resolved has
|
|
501
|
+
// already destroyed it, and no 'end' or 'error' would ever follow.
|
|
502
|
+
if (req.destroyed)
|
|
503
|
+
return reject(new Error('request aborted'));
|
|
244
504
|
let size = 0;
|
|
245
505
|
const chunks = [];
|
|
246
506
|
req.on('data', (c) => {
|
|
@@ -269,6 +529,12 @@ export class HttpTransportHost {
|
|
|
269
529
|
fail(res, status, error, message) {
|
|
270
530
|
if (res.writableEnded)
|
|
271
531
|
return;
|
|
532
|
+
// A response already started cannot become an error one; cutting it tells
|
|
533
|
+
// the client it is incomplete.
|
|
534
|
+
if (res.headersSent) {
|
|
535
|
+
res.destroy();
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
272
538
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
273
539
|
res.end(JSON.stringify({ error, message }));
|
|
274
540
|
}
|
package/dist/identity.d.ts
CHANGED
|
@@ -4,11 +4,13 @@ import { hasToken, readToken, updateToken, writeToken } from './token-store.js';
|
|
|
4
4
|
import { type Policy, type Transport } from './write-control.js';
|
|
5
5
|
/**
|
|
6
6
|
* The one forward-compat seam (frozen public API): the free core builds
|
|
7
|
-
* exactly one context with subject "owner";
|
|
8
|
-
* from N registries. Nothing here may assume a global current-account.
|
|
7
|
+
* exactly one context with subject "owner"; a multi-tenant host instantiates
|
|
8
|
+
* N contexts 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,19 +1,35 @@
|
|
|
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 owner-only surfaces, so a hand-built context claiming 'owner' stays non-owner.
|
|
7
|
+
const ownerContexts = new WeakSet();
|
|
8
|
+
export function isOwnerContext(ctx) {
|
|
9
|
+
return ctx !== undefined && ownerContexts.has(ctx);
|
|
10
|
+
}
|
|
5
11
|
export function buildIdentityContext(env = process.env, opts = {}) {
|
|
6
|
-
|
|
12
|
+
// The SAME factories a multi-tenant host instantiates per tenant, bound here
|
|
13
|
+
// to the global registry + token dir.
|
|
14
|
+
const tokenStore = createTokenStore(getTokenDir());
|
|
15
|
+
const ctx = {
|
|
7
16
|
subject: 'owner',
|
|
8
17
|
// Live getter: the seam must always see the current registry, never a
|
|
9
18
|
// snapshot pinned from before a wizard mutation or cross-process reload.
|
|
10
19
|
get accounts() {
|
|
11
20
|
return getAccountSet();
|
|
12
21
|
},
|
|
13
|
-
// The dispatch transport rides into the resolved policy as a reserved seam
|
|
14
|
-
//
|
|
22
|
+
// The dispatch transport rides into the resolved policy as a reserved seam;
|
|
23
|
+
// it does not change any write-control verdict.
|
|
15
24
|
policy: resolvePolicy(env, { transport: opts.transport }),
|
|
16
|
-
getClient,
|
|
17
|
-
|
|
25
|
+
getClient: makeGetClient('owner', {
|
|
26
|
+
accounts: getAccountSet,
|
|
27
|
+
readToken: tokenStore.readToken,
|
|
28
|
+
updateToken: tokenStore.updateToken,
|
|
29
|
+
refreshIfStale: refreshAccountSetIfStale,
|
|
30
|
+
}),
|
|
31
|
+
tokenStore,
|
|
18
32
|
};
|
|
33
|
+
ownerContexts.add(ctx);
|
|
34
|
+
return ctx;
|
|
19
35
|
}
|