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.
Files changed (182) hide show
  1. package/README.md +5 -3
  2. package/dist/accounts.d.ts +22 -1
  3. package/dist/accounts.js +64 -16
  4. package/dist/api-probe.d.ts +5 -0
  5. package/dist/api-probe.js +23 -15
  6. package/dist/arg-normalize.d.ts +61 -4
  7. package/dist/arg-normalize.js +229 -4
  8. package/dist/arg-strict.d.ts +61 -0
  9. package/dist/arg-strict.js +193 -0
  10. package/dist/auth.d.ts +4 -3
  11. package/dist/auth.js +26 -85
  12. package/dist/boot-gates.d.ts +26 -0
  13. package/dist/boot-gates.js +38 -0
  14. package/dist/cimd-cache.d.ts +38 -0
  15. package/dist/cimd-cache.js +117 -0
  16. package/dist/client.d.ts +32 -2
  17. package/dist/client.js +0 -0
  18. package/dist/compose.d.ts +11 -0
  19. package/dist/compose.js +109 -0
  20. package/dist/config-file.d.ts +14 -0
  21. package/dist/config-file.js +41 -1
  22. package/dist/discover.js +38 -15
  23. package/dist/discovery-client.d.ts +11 -1
  24. package/dist/discovery-client.js +120 -18
  25. package/dist/doctor.d.ts +15 -3
  26. package/dist/doctor.js +151 -27
  27. package/dist/env-load.d.ts +11 -0
  28. package/dist/env-load.js +23 -1
  29. package/dist/executor.d.ts +3 -0
  30. package/dist/executor.js +16 -3
  31. package/dist/fanout.d.ts +9 -1
  32. package/dist/fanout.js +83 -14
  33. package/dist/fs-atomic.d.ts +11 -0
  34. package/dist/fs-atomic.js +219 -28
  35. package/dist/hint-provider.d.ts +129 -0
  36. package/dist/hint-provider.js +64 -0
  37. package/dist/http-transport.d.ts +73 -6
  38. package/dist/http-transport.js +350 -26
  39. package/dist/identity.d.ts +4 -1
  40. package/dist/identity.js +25 -6
  41. package/dist/index.js +142 -114
  42. package/dist/master-key.js +13 -5
  43. package/dist/mcp-token.d.ts +136 -19
  44. package/dist/mcp-token.js +569 -72
  45. package/dist/metrics-cli.d.ts +21 -0
  46. package/dist/metrics-cli.js +206 -0
  47. package/dist/metrics-tap.d.ts +12 -0
  48. package/dist/metrics-tap.js +95 -0
  49. package/dist/migrate-config.js +2 -2
  50. package/dist/oauth-as.d.ts +103 -3
  51. package/dist/oauth-as.js +374 -79
  52. package/dist/oauth-consent.d.ts +35 -10
  53. package/dist/oauth-consent.js +101 -39
  54. package/dist/outbound-allowlist.d.ts +34 -0
  55. package/dist/outbound-allowlist.js +111 -0
  56. package/dist/reauth-hint.d.ts +1 -1
  57. package/dist/reauth-hint.js +12 -8
  58. package/dist/registry-transfer.d.ts +5 -0
  59. package/dist/registry-transfer.js +65 -5
  60. package/dist/registry.d.ts +66 -2
  61. package/dist/registry.js +214 -18
  62. package/dist/scope-catalog.d.ts +11 -0
  63. package/dist/scope-catalog.js +39 -6
  64. package/dist/scope-observability.d.ts +8 -6
  65. package/dist/scope-observability.js +29 -13
  66. package/dist/services.d.ts +17 -4
  67. package/dist/services.js +77 -6
  68. package/dist/setup-prompt.d.ts +1 -1
  69. package/dist/setup-prompt.js +1 -1
  70. package/dist/ssrf-guard.d.ts +23 -9
  71. package/dist/ssrf-guard.js +101 -62
  72. package/dist/tenant-purge.d.ts +26 -0
  73. package/dist/tenant-purge.js +67 -0
  74. package/dist/token-store.d.ts +20 -2
  75. package/dist/token-store.js +0 -0
  76. package/dist/tools/_coerce.d.ts +5 -0
  77. package/dist/tools/_coerce.js +11 -0
  78. package/dist/tools/_errors.d.ts +34 -3
  79. package/dist/tools/_errors.js +346 -53
  80. package/dist/tools/_local-files.d.ts +9 -0
  81. package/dist/tools/_local-files.js +25 -2
  82. package/dist/tools/account-wizard.d.ts +34 -2
  83. package/dist/tools/account-wizard.js +223 -65
  84. package/dist/tools/accounts-tool.d.ts +7 -0
  85. package/dist/tools/accounts-tool.js +34 -18
  86. package/dist/tools/admin.d.ts +10 -1
  87. package/dist/tools/admin.js +21 -16
  88. package/dist/tools/analytics.d.ts +27 -0
  89. package/dist/tools/analytics.js +289 -0
  90. package/dist/tools/calendar.d.ts +9 -1
  91. package/dist/tools/calendar.js +93 -29
  92. package/dist/tools/chat.d.ts +10 -1
  93. package/dist/tools/chat.js +20 -12
  94. package/dist/tools/contacts.d.ts +2 -1
  95. package/dist/tools/contacts.js +74 -43
  96. package/dist/tools/docs.d.ts +2 -1
  97. package/dist/tools/docs.js +90 -84
  98. package/dist/tools/drive.d.ts +2 -1
  99. package/dist/tools/drive.js +197 -120
  100. package/dist/tools/forms.d.ts +10 -1
  101. package/dist/tools/forms.js +26 -19
  102. package/dist/tools/generated/_shared.d.ts +8 -1
  103. package/dist/tools/generated/_shared.js +18 -4
  104. package/dist/tools/generated/admin.d.ts +2 -1
  105. package/dist/tools/generated/admin.js +610 -479
  106. package/dist/tools/generated/analytics.d.ts +3 -0
  107. package/dist/tools/generated/analytics.js +981 -0
  108. package/dist/tools/generated/appsmarket.d.ts +2 -1
  109. package/dist/tools/generated/appsmarket.js +9 -9
  110. package/dist/tools/generated/calendar.d.ts +2 -1
  111. package/dist/tools/generated/calendar.js +83 -83
  112. package/dist/tools/generated/chat.d.ts +2 -1
  113. package/dist/tools/generated/chat.js +178 -151
  114. package/dist/tools/generated/classroom.d.ts +2 -1
  115. package/dist/tools/generated/classroom.js +467 -425
  116. package/dist/tools/generated/cloudidentity.d.ts +2 -1
  117. package/dist/tools/generated/cloudidentity.js +209 -201
  118. package/dist/tools/generated/cloudsearch.d.ts +2 -1
  119. package/dist/tools/generated/cloudsearch.js +139 -138
  120. package/dist/tools/generated/contacts.d.ts +2 -1
  121. package/dist/tools/generated/contacts.js +49 -40
  122. package/dist/tools/generated/drive.d.ts +2 -1
  123. package/dist/tools/generated/drive.js +133 -124
  124. package/dist/tools/generated/driveactivity.d.ts +2 -1
  125. package/dist/tools/generated/driveactivity.js +3 -3
  126. package/dist/tools/generated/drivelabels.d.ts +2 -1
  127. package/dist/tools/generated/drivelabels.js +113 -85
  128. package/dist/tools/generated/forms.d.ts +2 -1
  129. package/dist/tools/generated/forms.js +14 -14
  130. package/dist/tools/generated/gmail.d.ts +2 -1
  131. package/dist/tools/generated/gmail.js +253 -226
  132. package/dist/tools/generated/groupsmigration.d.ts +2 -1
  133. package/dist/tools/generated/groupsmigration.js +4 -4
  134. package/dist/tools/generated/groupssettings.d.ts +2 -1
  135. package/dist/tools/generated/groupssettings.js +10 -10
  136. package/dist/tools/generated/index.d.ts +2 -1
  137. package/dist/tools/generated/index.js +2 -0
  138. package/dist/tools/generated/keep.d.ts +2 -1
  139. package/dist/tools/generated/keep.js +23 -22
  140. package/dist/tools/generated/licensing.d.ts +2 -1
  141. package/dist/tools/generated/licensing.js +54 -38
  142. package/dist/tools/generated/meet.d.ts +2 -1
  143. package/dist/tools/generated/meet.js +41 -41
  144. package/dist/tools/generated/method-map.d.ts +2 -0
  145. package/dist/tools/generated/method-map.js +894 -0
  146. package/dist/tools/generated/postmaster.d.ts +2 -1
  147. package/dist/tools/generated/postmaster.js +12 -12
  148. package/dist/tools/generated/reseller.d.ts +2 -1
  149. package/dist/tools/generated/reseller.js +69 -63
  150. package/dist/tools/generated/script.d.ts +2 -1
  151. package/dist/tools/generated/script.js +62 -52
  152. package/dist/tools/generated/searchconsole.d.ts +2 -1
  153. package/dist/tools/generated/searchconsole.js +8 -6
  154. package/dist/tools/generated/sheets.d.ts +2 -1
  155. package/dist/tools/generated/sheets.js +25 -24
  156. package/dist/tools/generated/tasks.d.ts +2 -1
  157. package/dist/tools/generated/tasks.js +15 -9
  158. package/dist/tools/generated/vault.d.ts +2 -1
  159. package/dist/tools/generated/vault.js +132 -123
  160. package/dist/tools/generated/workspaceevents.d.ts +2 -1
  161. package/dist/tools/generated/workspaceevents.js +48 -47
  162. package/dist/tools/gmail.d.ts +12 -2
  163. package/dist/tools/gmail.js +95 -51
  164. package/dist/tools/google-api.d.ts +2 -3
  165. package/dist/tools/google-api.js +76 -33
  166. package/dist/tools/meet.d.ts +10 -1
  167. package/dist/tools/meet.js +18 -11
  168. package/dist/tools/searchconsole.d.ts +2 -1
  169. package/dist/tools/searchconsole.js +27 -23
  170. package/dist/tools/sheets.d.ts +2 -1
  171. package/dist/tools/sheets.js +72 -80
  172. package/dist/tools/slides.d.ts +10 -1
  173. package/dist/tools/slides.js +23 -16
  174. package/dist/tools/tasks.d.ts +2 -1
  175. package/dist/tools/tasks.js +35 -31
  176. package/dist/trim.d.ts +26 -0
  177. package/dist/trim.js +67 -0
  178. package/dist/usage-metrics.d.ts +178 -0
  179. package/dist/usage-metrics.js +621 -0
  180. package/dist/write-control.d.ts +27 -1
  181. package/dist/write-control.js +99 -12
  182. package/package.json +34 -3
@@ -1,15 +1,39 @@
1
- // B12: the Streamable HTTP transport host (cc-transport-hosting T2/T3). Owns the
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 { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
8
- import { withArgNormalization } from './arg-normalize.js';
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
- /** 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>`. */
38
66
  export function jsonRpcMethod(body) {
39
- const one = (b) => b && typeof b === 'object' && 'method' in b ? String(b.method) : undefined;
40
- if (Array.isArray(body))
41
- return body.map(one).filter(Boolean).join(',') || 'batch';
42
- 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
+ }
43
116
  }
44
117
  export class HttpTransportHost {
45
118
  opts;
46
119
  httpServer;
47
- // Serialize the connect→dispatch critical section: the shared McpServer
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. Single-owner HTTP traffic is effectively serial, so a mutex
51
- // keeps correctness at negligible cost (and never rebuilds the registry).
52
- 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;
53
140
  constructor(opts) {
54
141
  this.opts = opts;
55
142
  }
56
- serialize(fn) {
57
- const run = this.lock.then(fn, fn);
58
- 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
+ });
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
- 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
+ });
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
- 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
+ }
84
250
  const s = this.httpServer;
85
251
  if (!s)
86
252
  return;
87
- await new Promise((resolve) => {
88
- s.close(() => resolve());
89
- 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);
90
297
  });
91
- 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
+ }
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 StreamableHTTPServerTransport({
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
- 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
+ }
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
- await this.opts.server.connect(this.opts.argShapeFor ? withArgNormalization(transport, this.opts.argShapeFor, this.opts.log) : transport);
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
  }
@@ -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
  }