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.
Files changed (181) hide show
  1. package/README.md +5 -5
  2. package/dist/accounts.d.ts +20 -4
  3. package/dist/accounts.js +68 -44
  4. package/dist/api-probe.d.ts +5 -0
  5. package/dist/api-probe.js +20 -15
  6. package/dist/arg-normalize.js +19 -38
  7. package/dist/arg-strict.d.ts +7 -0
  8. package/dist/arg-strict.js +38 -28
  9. package/dist/auth.d.ts +5 -4
  10. package/dist/auth.js +16 -23
  11. package/dist/boot-gates.d.ts +26 -0
  12. package/dist/boot-gates.js +36 -0
  13. package/dist/cimd-cache.d.ts +38 -0
  14. package/dist/cimd-cache.js +111 -0
  15. package/dist/client-config.d.ts +2 -2
  16. package/dist/client-config.js +8 -14
  17. package/dist/client.d.ts +32 -2
  18. package/dist/client.js +0 -0
  19. package/dist/compose.d.ts +18 -0
  20. package/dist/compose.js +109 -0
  21. package/dist/config-file.d.ts +13 -0
  22. package/dist/config-file.js +41 -10
  23. package/dist/discover.js +7 -10
  24. package/dist/discovery-client.d.ts +3 -0
  25. package/dist/discovery-client.js +36 -32
  26. package/dist/doctor.d.ts +9 -5
  27. package/dist/doctor.js +119 -29
  28. package/dist/env-load.js +2 -5
  29. package/dist/executor.d.ts +3 -0
  30. package/dist/executor.js +6 -6
  31. package/dist/fanout.d.ts +9 -1
  32. package/dist/fanout.js +47 -22
  33. package/dist/fs-atomic.d.ts +8 -0
  34. package/dist/fs-atomic.js +216 -29
  35. package/dist/hint-provider.d.ts +129 -0
  36. package/dist/hint-provider.js +58 -0
  37. package/dist/http-config.d.ts +2 -2
  38. package/dist/http-config.js +3 -7
  39. package/dist/http-transport.d.ts +67 -9
  40. package/dist/http-transport.js +318 -52
  41. package/dist/identity.d.ts +6 -3
  42. package/dist/identity.js +24 -8
  43. package/dist/index.js +76 -160
  44. package/dist/master-key.d.ts +3 -3
  45. package/dist/master-key.js +16 -19
  46. package/dist/mcp-token.d.ts +126 -19
  47. package/dist/mcp-token.js +557 -76
  48. package/dist/metrics-cli.d.ts +1 -1
  49. package/dist/metrics-cli.js +1 -1
  50. package/dist/metrics-tap.js +2 -5
  51. package/dist/migrate-config.d.ts +1 -2
  52. package/dist/migrate-config.js +5 -7
  53. package/dist/net-tuning.js +2 -4
  54. package/dist/oauth-as.d.ts +104 -4
  55. package/dist/oauth-as.js +368 -114
  56. package/dist/oauth-consent.d.ts +10 -0
  57. package/dist/oauth-consent.js +20 -10
  58. package/dist/outbound-allowlist.js +9 -11
  59. package/dist/reauth-hint.d.ts +1 -1
  60. package/dist/reauth-hint.js +11 -10
  61. package/dist/registry-transfer.d.ts +5 -0
  62. package/dist/registry-transfer.js +63 -11
  63. package/dist/registry.d.ts +13 -4
  64. package/dist/registry.js +100 -72
  65. package/dist/scope-catalog.d.ts +16 -0
  66. package/dist/scope-catalog.js +40 -13
  67. package/dist/scope-observability.d.ts +10 -9
  68. package/dist/scope-observability.js +34 -24
  69. package/dist/services.d.ts +18 -10
  70. package/dist/services.js +61 -22
  71. package/dist/ssrf-guard.d.ts +23 -9
  72. package/dist/ssrf-guard.js +106 -78
  73. package/dist/tenant-purge.d.ts +26 -0
  74. package/dist/tenant-purge.js +65 -0
  75. package/dist/token-store.d.ts +20 -2
  76. package/dist/token-store.js +0 -0
  77. package/dist/tools/_coerce.d.ts +4 -4
  78. package/dist/tools/_errors.d.ts +3 -0
  79. package/dist/tools/_errors.js +72 -60
  80. package/dist/tools/_local-files.d.ts +9 -0
  81. package/dist/tools/_local-files.js +22 -8
  82. package/dist/tools/account-wizard.d.ts +13 -3
  83. package/dist/tools/account-wizard.js +107 -56
  84. package/dist/tools/accounts-tool.d.ts +8 -1
  85. package/dist/tools/accounts-tool.js +35 -19
  86. package/dist/tools/admin.d.ts +10 -1
  87. package/dist/tools/admin.js +14 -11
  88. package/dist/tools/analytics.d.ts +10 -1
  89. package/dist/tools/analytics.js +14 -10
  90. package/dist/tools/calendar.d.ts +2 -1
  91. package/dist/tools/calendar.js +19 -18
  92. package/dist/tools/chat.d.ts +10 -1
  93. package/dist/tools/chat.js +12 -8
  94. package/dist/tools/contacts.d.ts +2 -1
  95. package/dist/tools/contacts.js +22 -19
  96. package/dist/tools/docs.d.ts +2 -1
  97. package/dist/tools/docs.js +35 -36
  98. package/dist/tools/drive.d.ts +2 -1
  99. package/dist/tools/drive.js +80 -74
  100. package/dist/tools/forms.d.ts +10 -1
  101. package/dist/tools/forms.js +18 -12
  102. package/dist/tools/generated/_shared.d.ts +2 -1
  103. package/dist/tools/generated/_shared.js +7 -3
  104. package/dist/tools/generated/admin.d.ts +2 -1
  105. package/dist/tools/generated/admin.js +265 -265
  106. package/dist/tools/generated/analytics.d.ts +2 -1
  107. package/dist/tools/generated/analytics.js +125 -125
  108. package/dist/tools/generated/appsmarket.d.ts +2 -1
  109. package/dist/tools/generated/appsmarket.js +5 -5
  110. package/dist/tools/generated/calendar.d.ts +2 -1
  111. package/dist/tools/generated/calendar.js +55 -55
  112. package/dist/tools/generated/chat.d.ts +2 -1
  113. package/dist/tools/generated/chat.js +95 -95
  114. package/dist/tools/generated/classroom.d.ts +2 -1
  115. package/dist/tools/generated/classroom.js +209 -209
  116. package/dist/tools/generated/cloudidentity.d.ts +2 -1
  117. package/dist/tools/generated/cloudidentity.js +133 -133
  118. package/dist/tools/generated/cloudsearch.d.ts +2 -1
  119. package/dist/tools/generated/cloudsearch.js +99 -99
  120. package/dist/tools/generated/contacts.d.ts +2 -1
  121. package/dist/tools/generated/contacts.js +29 -29
  122. package/dist/tools/generated/drive.d.ts +2 -1
  123. package/dist/tools/generated/drive.js +71 -71
  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 +53 -53
  128. package/dist/tools/generated/forms.d.ts +2 -1
  129. package/dist/tools/generated/forms.js +7 -7
  130. package/dist/tools/generated/gmail.d.ts +2 -1
  131. package/dist/tools/generated/gmail.js +117 -117
  132. package/dist/tools/generated/groupsmigration.d.ts +2 -1
  133. package/dist/tools/generated/groupsmigration.js +3 -3
  134. package/dist/tools/generated/groupssettings.d.ts +2 -1
  135. package/dist/tools/generated/groupssettings.js +7 -7
  136. package/dist/tools/generated/index.d.ts +2 -1
  137. package/dist/tools/generated/keep.d.ts +2 -1
  138. package/dist/tools/generated/keep.js +15 -15
  139. package/dist/tools/generated/licensing.d.ts +2 -1
  140. package/dist/tools/generated/licensing.js +15 -15
  141. package/dist/tools/generated/meet.d.ts +2 -1
  142. package/dist/tools/generated/meet.js +27 -27
  143. package/dist/tools/generated/postmaster.d.ts +2 -1
  144. package/dist/tools/generated/postmaster.js +9 -9
  145. package/dist/tools/generated/reseller.d.ts +2 -1
  146. package/dist/tools/generated/reseller.js +35 -35
  147. package/dist/tools/generated/script.d.ts +2 -1
  148. package/dist/tools/generated/script.js +33 -33
  149. package/dist/tools/generated/searchconsole.d.ts +2 -1
  150. package/dist/tools/generated/searchconsole.js +3 -3
  151. package/dist/tools/generated/sheets.d.ts +2 -1
  152. package/dist/tools/generated/sheets.js +15 -15
  153. package/dist/tools/generated/tasks.d.ts +2 -1
  154. package/dist/tools/generated/tasks.js +5 -5
  155. package/dist/tools/generated/vault.d.ts +2 -1
  156. package/dist/tools/generated/vault.js +67 -67
  157. package/dist/tools/generated/workspaceevents.d.ts +2 -1
  158. package/dist/tools/generated/workspaceevents.js +31 -31
  159. package/dist/tools/gmail-mime.d.ts +1 -1
  160. package/dist/tools/gmail-mime.js +7 -12
  161. package/dist/tools/gmail.d.ts +4 -3
  162. package/dist/tools/gmail.js +67 -69
  163. package/dist/tools/google-api.d.ts +2 -3
  164. package/dist/tools/google-api.js +34 -22
  165. package/dist/tools/meet.d.ts +10 -1
  166. package/dist/tools/meet.js +12 -9
  167. package/dist/tools/searchconsole.d.ts +2 -1
  168. package/dist/tools/searchconsole.js +16 -13
  169. package/dist/tools/sheets.d.ts +2 -1
  170. package/dist/tools/sheets.js +35 -32
  171. package/dist/tools/slides.d.ts +10 -1
  172. package/dist/tools/slides.js +16 -10
  173. package/dist/tools/tasks.d.ts +2 -1
  174. package/dist/tools/tasks.js +18 -15
  175. package/dist/trim.d.ts +5 -0
  176. package/dist/trim.js +19 -0
  177. package/dist/usage-metrics.d.ts +23 -0
  178. package/dist/usage-metrics.js +67 -56
  179. package/dist/write-control.d.ts +4 -8
  180. package/dist/write-control.js +13 -6
  181. package/package.json +47 -21
@@ -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. Generous by default so slow-but-valid calls (large
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, BV-4). */
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
- /** JSON-RPC method name(s) for an observability log line — no params, no PII. */
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) => 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';
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
- // Serialize the connect→dispatch critical section: the shared McpServer
47
- // captures its transport per request (protocol.js), but the gap between
48
- // 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();
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
- serialize(fn) {
56
- const run = this.lock.then(fn, fn);
57
- this.lock = run.then(() => undefined, () => undefined);
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
- this.handle(req, res).catch((e) => this.fail(res, 500, 'internal_error', e.message));
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
- async close() {
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
- await new Promise((resolve) => {
87
- s.close(() => resolve());
88
- s.closeAllConnections?.();
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
- this.httpServer = undefined;
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
- // Host/Origin are enforced by the front guard above (uniformly for /mcp and
160
- // the mounted AS routes), so the SDK's own DNS-rebind guard is disabled: its
161
- // exact-Host match is stricter than the front guard and would 403 valid
162
- // Hosts (double enforcement, differing rules).
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
- // The shared McpServer holds exactly one connected transport at a time, so
169
- // connect + dispatch + DISCONNECT all run inside the mutex: the next request
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
- await this.serialize(async () => {
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
- // Same composition as the stdio leg: the envelope rewrite is innermost,
186
- // so the tap above it still classifies the original validation prose.
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 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)
446
+ await mcpServer.connect(argShapeFor || strictArgs
447
+ ? withArgNormalization(tapped, argShapeFor ?? (() => undefined), this.opts.log, this.opts.onArgRename, strictArgs)
191
448
  : tapped);
192
- // Reflect the dispatch into a non-rejecting arm: if the deadline wins the
193
- // race, an orphaned handler settling later must not surface as an unhandled
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 (cc-transport-hosting /health rule).
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
  }
@@ -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"; EE later instantiates N contexts
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: '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,19 +1,35 @@
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 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
- return {
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
- // (cc-write-control B14); it does not change any write-control verdict.
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
- tokenStore: { readToken, writeToken, updateToken, hasToken },
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
  }