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.
Files changed (165) hide show
  1. package/dist/accounts.d.ts +17 -1
  2. package/dist/accounts.js +52 -15
  3. package/dist/api-probe.d.ts +5 -0
  4. package/dist/api-probe.js +20 -15
  5. package/dist/arg-normalize.js +6 -5
  6. package/dist/arg-strict.d.ts +7 -0
  7. package/dist/arg-strict.js +34 -4
  8. package/dist/auth.d.ts +4 -3
  9. package/dist/auth.js +11 -13
  10. package/dist/boot-gates.d.ts +26 -0
  11. package/dist/boot-gates.js +38 -0
  12. package/dist/cimd-cache.d.ts +38 -0
  13. package/dist/cimd-cache.js +117 -0
  14. package/dist/client.d.ts +32 -2
  15. package/dist/client.js +0 -0
  16. package/dist/compose.d.ts +11 -0
  17. package/dist/compose.js +109 -0
  18. package/dist/config-file.d.ts +13 -0
  19. package/dist/config-file.js +37 -1
  20. package/dist/discover.js +3 -1
  21. package/dist/discovery-client.d.ts +3 -0
  22. package/dist/discovery-client.js +25 -2
  23. package/dist/doctor.d.ts +6 -2
  24. package/dist/doctor.js +119 -23
  25. package/dist/executor.d.ts +3 -0
  26. package/dist/executor.js +4 -2
  27. package/dist/fanout.d.ts +9 -1
  28. package/dist/fanout.js +44 -10
  29. package/dist/fs-atomic.d.ts +11 -0
  30. package/dist/fs-atomic.js +219 -28
  31. package/dist/hint-provider.d.ts +129 -0
  32. package/dist/hint-provider.js +64 -0
  33. package/dist/http-transport.d.ts +62 -4
  34. package/dist/http-transport.js +343 -24
  35. package/dist/identity.d.ts +4 -1
  36. package/dist/identity.js +25 -6
  37. package/dist/index.js +66 -138
  38. package/dist/master-key.js +13 -5
  39. package/dist/mcp-token.d.ts +136 -19
  40. package/dist/mcp-token.js +569 -72
  41. package/dist/migrate-config.js +2 -2
  42. package/dist/oauth-as.d.ts +103 -3
  43. package/dist/oauth-as.js +374 -79
  44. package/dist/oauth-consent.d.ts +10 -0
  45. package/dist/oauth-consent.js +16 -0
  46. package/dist/outbound-allowlist.js +5 -2
  47. package/dist/reauth-hint.d.ts +1 -1
  48. package/dist/reauth-hint.js +12 -8
  49. package/dist/registry-transfer.d.ts +5 -0
  50. package/dist/registry-transfer.js +65 -5
  51. package/dist/registry.d.ts +18 -1
  52. package/dist/registry.js +71 -15
  53. package/dist/scope-catalog.d.ts +11 -0
  54. package/dist/scope-catalog.js +23 -6
  55. package/dist/scope-observability.d.ts +8 -6
  56. package/dist/scope-observability.js +29 -13
  57. package/dist/services.d.ts +15 -10
  58. package/dist/services.js +59 -15
  59. package/dist/ssrf-guard.d.ts +23 -9
  60. package/dist/ssrf-guard.js +101 -62
  61. package/dist/tenant-purge.d.ts +26 -0
  62. package/dist/tenant-purge.js +67 -0
  63. package/dist/token-store.d.ts +20 -2
  64. package/dist/token-store.js +0 -0
  65. package/dist/tools/_errors.d.ts +3 -0
  66. package/dist/tools/_errors.js +53 -24
  67. package/dist/tools/_local-files.d.ts +9 -0
  68. package/dist/tools/_local-files.js +18 -0
  69. package/dist/tools/account-wizard.d.ts +10 -1
  70. package/dist/tools/account-wizard.js +74 -17
  71. package/dist/tools/accounts-tool.d.ts +7 -0
  72. package/dist/tools/accounts-tool.js +34 -18
  73. package/dist/tools/admin.d.ts +10 -1
  74. package/dist/tools/admin.js +15 -11
  75. package/dist/tools/analytics.d.ts +10 -1
  76. package/dist/tools/analytics.js +13 -8
  77. package/dist/tools/calendar.d.ts +2 -1
  78. package/dist/tools/calendar.js +18 -14
  79. package/dist/tools/chat.d.ts +10 -1
  80. package/dist/tools/chat.js +13 -8
  81. package/dist/tools/contacts.d.ts +2 -1
  82. package/dist/tools/contacts.js +17 -13
  83. package/dist/tools/docs.d.ts +2 -1
  84. package/dist/tools/docs.js +34 -30
  85. package/dist/tools/drive.d.ts +2 -1
  86. package/dist/tools/drive.js +65 -50
  87. package/dist/tools/forms.d.ts +10 -1
  88. package/dist/tools/forms.js +19 -12
  89. package/dist/tools/generated/_shared.d.ts +2 -1
  90. package/dist/tools/generated/_shared.js +7 -3
  91. package/dist/tools/generated/admin.d.ts +2 -1
  92. package/dist/tools/generated/admin.js +265 -265
  93. package/dist/tools/generated/analytics.d.ts +2 -1
  94. package/dist/tools/generated/analytics.js +125 -125
  95. package/dist/tools/generated/appsmarket.d.ts +2 -1
  96. package/dist/tools/generated/appsmarket.js +5 -5
  97. package/dist/tools/generated/calendar.d.ts +2 -1
  98. package/dist/tools/generated/calendar.js +55 -55
  99. package/dist/tools/generated/chat.d.ts +2 -1
  100. package/dist/tools/generated/chat.js +95 -95
  101. package/dist/tools/generated/classroom.d.ts +2 -1
  102. package/dist/tools/generated/classroom.js +209 -209
  103. package/dist/tools/generated/cloudidentity.d.ts +2 -1
  104. package/dist/tools/generated/cloudidentity.js +133 -133
  105. package/dist/tools/generated/cloudsearch.d.ts +2 -1
  106. package/dist/tools/generated/cloudsearch.js +99 -99
  107. package/dist/tools/generated/contacts.d.ts +2 -1
  108. package/dist/tools/generated/contacts.js +29 -29
  109. package/dist/tools/generated/drive.d.ts +2 -1
  110. package/dist/tools/generated/drive.js +71 -71
  111. package/dist/tools/generated/driveactivity.d.ts +2 -1
  112. package/dist/tools/generated/driveactivity.js +3 -3
  113. package/dist/tools/generated/drivelabels.d.ts +2 -1
  114. package/dist/tools/generated/drivelabels.js +53 -53
  115. package/dist/tools/generated/forms.d.ts +2 -1
  116. package/dist/tools/generated/forms.js +7 -7
  117. package/dist/tools/generated/gmail.d.ts +2 -1
  118. package/dist/tools/generated/gmail.js +117 -117
  119. package/dist/tools/generated/groupsmigration.d.ts +2 -1
  120. package/dist/tools/generated/groupsmigration.js +3 -3
  121. package/dist/tools/generated/groupssettings.d.ts +2 -1
  122. package/dist/tools/generated/groupssettings.js +7 -7
  123. package/dist/tools/generated/index.d.ts +2 -1
  124. package/dist/tools/generated/keep.d.ts +2 -1
  125. package/dist/tools/generated/keep.js +15 -15
  126. package/dist/tools/generated/licensing.d.ts +2 -1
  127. package/dist/tools/generated/licensing.js +15 -15
  128. package/dist/tools/generated/meet.d.ts +2 -1
  129. package/dist/tools/generated/meet.js +27 -27
  130. package/dist/tools/generated/postmaster.d.ts +2 -1
  131. package/dist/tools/generated/postmaster.js +9 -9
  132. package/dist/tools/generated/reseller.d.ts +2 -1
  133. package/dist/tools/generated/reseller.js +35 -35
  134. package/dist/tools/generated/script.d.ts +2 -1
  135. package/dist/tools/generated/script.js +33 -33
  136. package/dist/tools/generated/searchconsole.d.ts +2 -1
  137. package/dist/tools/generated/searchconsole.js +3 -3
  138. package/dist/tools/generated/sheets.d.ts +2 -1
  139. package/dist/tools/generated/sheets.js +15 -15
  140. package/dist/tools/generated/tasks.d.ts +2 -1
  141. package/dist/tools/generated/tasks.js +5 -5
  142. package/dist/tools/generated/vault.d.ts +2 -1
  143. package/dist/tools/generated/vault.js +67 -67
  144. package/dist/tools/generated/workspaceevents.d.ts +2 -1
  145. package/dist/tools/generated/workspaceevents.js +31 -31
  146. package/dist/tools/gmail.d.ts +2 -1
  147. package/dist/tools/gmail.js +45 -33
  148. package/dist/tools/google-api.d.ts +2 -3
  149. package/dist/tools/google-api.js +30 -16
  150. package/dist/tools/meet.d.ts +10 -1
  151. package/dist/tools/meet.js +13 -9
  152. package/dist/tools/searchconsole.d.ts +2 -1
  153. package/dist/tools/searchconsole.js +17 -13
  154. package/dist/tools/sheets.d.ts +2 -1
  155. package/dist/tools/sheets.js +36 -32
  156. package/dist/tools/slides.d.ts +10 -1
  157. package/dist/tools/slides.js +17 -10
  158. package/dist/tools/tasks.d.ts +2 -1
  159. package/dist/tools/tasks.js +19 -15
  160. package/dist/trim.d.ts +5 -0
  161. package/dist/trim.js +20 -0
  162. package/dist/usage-metrics.d.ts +23 -0
  163. package/dist/usage-metrics.js +65 -47
  164. package/dist/write-control.js +10 -2
  165. package/package.json +27 -1
@@ -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
- /** JSON-RPC method name(s) for an observability log line — no params, no PII. */
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) => b && typeof b === 'object' && 'method' in b ? String(b.method) : undefined;
39
- if (Array.isArray(body))
40
- return body.map(one).filter(Boolean).join(',') || 'batch';
41
- return one(body) ?? 'unknown';
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: the shared McpServer
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. Single-owner HTTP traffic is effectively serial, so a mutex
50
- // keeps correctness at negligible cost (and never rebuilds the registry).
51
- lock = Promise.resolve();
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
- serialize(fn) {
56
- const run = this.lock.then(fn, fn);
57
- this.lock = run.then(() => undefined, () => undefined);
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
- this.handle(req, res).catch((e) => this.fail(res, 500, 'internal_error', e.message));
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
- async close() {
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
- await new Promise((resolve) => {
87
- s.close(() => resolve());
88
- s.closeAllConnections?.();
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
- this.httpServer = undefined;
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
- await this.serialize(async () => {
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, this.opts.validationEnvelope ?? {});
496
+ const enveloped = withValidationEnvelope(transport, validationEnvelope ?? {});
188
497
  const tapped = this.opts.metricsTap ? this.opts.metricsTap(enveloped) : enveloped;
189
- await this.opts.server.connect(this.opts.argShapeFor || this.opts.strictArgs
190
- ? withArgNormalization(tapped, this.opts.argShapeFor ?? (() => undefined), this.opts.log, this.opts.onArgRename, this.opts.strictArgs)
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
  }
@@ -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: 'owner';
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 { getClient } from './client.js';
3
- import { hasToken, readToken, updateToken, writeToken } from './token-store.js';
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
- return {
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
- tokenStore: { readToken, writeToken, updateToken, hasToken },
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
  }