mcpose 2.1.1 → 3.1.0

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 (101) hide show
  1. package/README.md +461 -62
  2. package/dist/backendClient.d.ts +1 -1
  3. package/dist/backendClient.d.ts.map +1 -1
  4. package/dist/backendClient.js +6 -1
  5. package/dist/backendClient.js.map +1 -1
  6. package/dist/core.d.ts +309 -35
  7. package/dist/core.d.ts.map +1 -1
  8. package/dist/core.js +1023 -186
  9. package/dist/core.js.map +1 -1
  10. package/dist/delegation.d.ts +89 -0
  11. package/dist/delegation.d.ts.map +1 -0
  12. package/dist/delegation.js +175 -0
  13. package/dist/delegation.js.map +1 -0
  14. package/dist/eventStore.d.ts +12 -0
  15. package/dist/eventStore.d.ts.map +1 -1
  16. package/dist/eventStore.js +43 -5
  17. package/dist/eventStore.js.map +1 -1
  18. package/dist/hiddenTools.d.ts +42 -0
  19. package/dist/hiddenTools.d.ts.map +1 -0
  20. package/dist/hiddenTools.js +53 -0
  21. package/dist/hiddenTools.js.map +1 -0
  22. package/dist/index.d.ts +15 -6
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +7 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/mesh.d.ts +82 -0
  27. package/dist/mesh.d.ts.map +1 -0
  28. package/dist/mesh.js +141 -0
  29. package/dist/mesh.js.map +1 -0
  30. package/dist/middleware.d.ts +13 -0
  31. package/dist/middleware.d.ts.map +1 -1
  32. package/dist/middleware.js +27 -3
  33. package/dist/middleware.js.map +1 -1
  34. package/dist/proxyContext.d.ts +65 -5
  35. package/dist/proxyContext.d.ts.map +1 -1
  36. package/dist/proxyContext.js +39 -4
  37. package/dist/proxyContext.js.map +1 -1
  38. package/dist/rejection.d.ts +7 -1
  39. package/dist/rejection.d.ts.map +1 -1
  40. package/dist/rejection.js +24 -1
  41. package/dist/rejection.js.map +1 -1
  42. package/dist/sanitizeCatalog.d.ts +37 -0
  43. package/dist/sanitizeCatalog.d.ts.map +1 -0
  44. package/dist/sanitizeCatalog.js +92 -0
  45. package/dist/sanitizeCatalog.js.map +1 -0
  46. package/dist/sessionRegistry.d.ts +79 -0
  47. package/dist/sessionRegistry.d.ts.map +1 -0
  48. package/dist/sessionRegistry.js +83 -0
  49. package/dist/sessionRegistry.js.map +1 -0
  50. package/dist/telemetry.d.ts +29 -1
  51. package/dist/telemetry.d.ts.map +1 -1
  52. package/dist/testing.d.ts +18 -2
  53. package/dist/testing.d.ts.map +1 -1
  54. package/dist/testing.js +30 -8
  55. package/dist/testing.js.map +1 -1
  56. package/dist/version.d.ts +7 -0
  57. package/dist/version.d.ts.map +1 -0
  58. package/dist/version.js +7 -0
  59. package/dist/version.js.map +1 -0
  60. package/package.json +22 -8
  61. package/src/__tests__/_helpers.ts +64 -0
  62. package/src/__tests__/backendClient.test.ts +198 -0
  63. package/src/__tests__/core.delegation.test.ts +964 -0
  64. package/src/__tests__/core.edge.test.ts +314 -0
  65. package/src/__tests__/core.hiddenTools.predicate.test.ts +236 -0
  66. package/src/__tests__/core.localTools.test.ts +314 -0
  67. package/src/__tests__/core.mesh.test.ts +1182 -0
  68. package/src/__tests__/core.stripRequestMeta.test.ts +226 -0
  69. package/src/__tests__/core.stripResultMeta.test.ts +217 -0
  70. package/src/__tests__/core.test.ts +1105 -0
  71. package/src/__tests__/core.toolCallTelemetry.test.ts +157 -0
  72. package/src/__tests__/eventStore.test.ts +141 -0
  73. package/src/__tests__/hasToolContent.test.ts +63 -0
  74. package/src/__tests__/mapToolResult.test.ts +101 -0
  75. package/src/__tests__/middleware.edge.test.ts +126 -0
  76. package/src/__tests__/middleware.test.ts +233 -0
  77. package/src/__tests__/proxyContext.test.ts +140 -0
  78. package/src/__tests__/sanitizeCatalog.test.ts +380 -0
  79. package/src/__tests__/startHttpProxy.lifecycle.test.ts +675 -0
  80. package/src/__tests__/startHttpProxy.registry.test.ts +409 -0
  81. package/src/__tests__/startHttpProxy.replay.test.ts +116 -0
  82. package/src/__tests__/startHttpProxy.security.test.ts +288 -0
  83. package/src/__tests__/startHttpProxy.test.ts +1065 -0
  84. package/src/__tests__/testing.test.ts +250 -0
  85. package/src/__tests__/version.test.ts +12 -0
  86. package/src/backendClient.ts +83 -0
  87. package/src/core.ts +2129 -0
  88. package/src/delegation.ts +224 -0
  89. package/src/eventStore.ts +106 -0
  90. package/src/hiddenTools.ts +84 -0
  91. package/src/identity.ts +15 -0
  92. package/src/index.ts +63 -0
  93. package/src/mesh.ts +204 -0
  94. package/src/middleware.ts +101 -0
  95. package/src/proxyContext.ts +108 -0
  96. package/src/rejection.ts +62 -0
  97. package/src/sanitizeCatalog.ts +126 -0
  98. package/src/sessionRegistry.ts +142 -0
  99. package/src/telemetry.ts +46 -0
  100. package/src/testing.ts +174 -0
  101. package/src/version.ts +6 -0
package/dist/core.js CHANGED
@@ -3,54 +3,131 @@ import { AsyncLocalStorage } from 'node:async_hooks';
3
3
  import * as http from 'node:http';
4
4
  import * as https from 'node:https';
5
5
  import { randomUUID } from 'node:crypto';
6
- import { createRequire } from 'node:module';
7
6
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
8
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
8
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
10
- import { CallToolRequestSchema, ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, PromptListChangedNotificationSchema, ReadResourceRequestSchema, ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, } from '@modelcontextprotocol/sdk/types.js';
11
- import { pipe } from './middleware.js';
12
- import { createProxyContext } from './proxyContext.js';
13
- import { createInMemoryEventStore } from './eventStore.js';
14
- import { rejectionMcpError } from './rejection.js';
15
- // Source our own package version once at module load. `createRequire` works under
16
- // ESM on Node 18+ without the `--experimental-json-modules` flag and avoids the
17
- // tsconfig and runtime caveats of a static JSON import.
18
- const readPackageJson = createRequire(import.meta.url);
19
- const pkg = readPackageJson('../package.json');
20
- /** Name the proxy advertises when `ProxyOptions.name` is omitted. */
21
- const DEFAULT_PROXY_NAME = 'mcpose';
9
+ import { CallToolRequestSchema, ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, PromptListChangedNotificationSchema, ReadResourceRequestSchema, ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
10
+ import { pipe, isPassThroughObserver } from './middleware.js';
11
+ import { createProxyContext, } from './proxyContext.js';
12
+ import { listAcrossMesh, namespaceName, namespaceUri, normalizeBackends, routeNamespaced, routeResourceUri, } from './mesh.js';
13
+ import { attachDelegationMeta, detectDelegationLoop, readInboundDelegation, } from './delegation.js';
14
+ import { createInMemoryEventStore, scopeEventStore } from './eventStore.js';
15
+ import { initializeParamsOf, replayInitialize, } from './sessionRegistry.js';
16
+ import { isRejectionReason, rejectionMcpError } from './rejection.js';
17
+ import { VERSION } from './version.js';
22
18
  /**
23
- * Narrows `CompatibilityCallToolResult` to `CallToolResult` (has `.content` array).
24
- * Both union members carry `[x: string]: unknown`, so this avoids unsafe casts.
19
+ * The result channels of a `CallToolResult` — everything a redaction or
20
+ * transform middleware must account for:
21
+ *
22
+ * 1. `content` blocks (text, image, audio, resource link, embedded
23
+ * resource), each carrying its own block-level `_meta`,
24
+ * 2. `structuredContent`, the machine-readable mirror of the content,
25
+ * 3. result-level `_meta`,
26
+ * 4. `isError`,
27
+ * 5. unknown extra keys via the index signature.
28
+ *
29
+ * Redaction that maps only `.content` misses 2, 3, and the non-text
30
+ * members of 1. Use {@link mapToolResult} to cover every payload channel
31
+ * by construction; channel 3 is stripped at the proxy boundary by default
32
+ * via `ProxyOptions.stripResultMeta` (ADR-0009).
33
+ *
34
+ * This guard narrows `CompatibilityCallToolResult` to `CallToolResult`
35
+ * (has `.content` array). Both union members carry `[x: string]: unknown`,
36
+ * so this avoids unsafe casts.
37
+ *
38
+ * A guard, not an assertion: the union's two members are both objects, but a
39
+ * pipeline result is not the union. The legacy shape has no `.content`, and a
40
+ * handler that produced nothing at all (a local tool with a missing `return`,
41
+ * or a middleware that dropped the result) produces a nullish one, which has
42
+ * none either. Both answer `false` here. Throwing instead replaced whatever
43
+ * the pipeline produced with `TypeError: Cannot read properties of undefined
44
+ * (reading 'content')` — inside `mapToolResult`, and inside the `tools/call`
45
+ * handler's telemetry read of `result.isError`.
25
46
  */
26
47
  export function hasToolContent(r) {
27
- return Array.isArray(r.content);
48
+ return (r !== null &&
49
+ typeof r === 'object' &&
50
+ Array.isArray(r.content));
51
+ }
52
+ /**
53
+ * Maps every payload channel of a tool result through the given handlers.
54
+ *
55
+ * The legacy `{ toolResult }` shape (protocol 2024-10-07) is returned
56
+ * untouched. `isError`, result-level `_meta`, and unknown extra keys are
57
+ * preserved by spread — result-level `_meta` needs no handler because the
58
+ * proxy strips it by default (`stripResultMeta`, ADR-0009); with
59
+ * `stripResultMeta: false` forwarding it is the consumer's explicit choice.
60
+ */
61
+ export function mapToolResult(result, handlers) {
62
+ if (!hasToolContent(result))
63
+ return result;
64
+ const content = result.content
65
+ .map((block) => block.type === 'text' ? handlers.onText(block) : handlers.onOther(block))
66
+ .filter((block) => block !== null);
67
+ const structured = result.structuredContent === undefined
68
+ ? undefined
69
+ : handlers.onStructured(result.structuredContent);
70
+ const { structuredContent: _dropped, ...rest } = result;
71
+ return {
72
+ ...rest,
73
+ content,
74
+ ...(structured === undefined ? {} : { structuredContent: structured }),
75
+ };
76
+ }
77
+ /**
78
+ * Hands one event to the telemetry sink. A sink that throws, or whose returned
79
+ * promise rejects, is logged here and nowhere else: the caller is already past
80
+ * the point where the tool call could fail, and a rejection that nobody observes
81
+ * ends the process on Node's default `--unhandled-rejections=throw`.
82
+ *
83
+ * The sink is called, not awaited, so a slow exporter cannot delay a tool call.
84
+ *
85
+ * `Promise.resolve` rather than an `instanceof Promise` test: a sink loaded from
86
+ * another realm (`node:vm`), or one handing back a non-native thenable, would
87
+ * fail the latter and leave its rejection unobserved all the same.
88
+ */
89
+ function callTelemetrySink(sink, event) {
90
+ try {
91
+ Promise.resolve(sink?.(event)).catch((err) => {
92
+ console.error(err);
93
+ });
94
+ }
95
+ catch (err) {
96
+ console.error(err);
97
+ }
28
98
  }
29
99
  const listChangedBuses = new WeakMap();
30
100
  const httpProxyContext = new AsyncLocalStorage();
31
- function createProxyCapabilities(backend) {
32
- const upstream = backend.getServerCapabilities();
101
+ /**
102
+ * Union of every backend's capabilities, plus the ADR-0007 rule that a
103
+ * non-empty `localTools` advertises `tools` on its own.
104
+ */
105
+ function createProxyCapabilities(entries, hasLocalTools) {
106
+ const upstreams = entries.map((entry) => entry.client.getServerCapabilities());
107
+ const listChanged = (supported) => supported ? { listChanged: true } : {};
33
108
  return {
34
- ...(upstream?.tools
35
- ? { tools: upstream.tools.listChanged ? { listChanged: true } : {} }
109
+ ...(upstreams.some((u) => u?.tools) || hasLocalTools
110
+ ? { tools: listChanged(upstreams.some((u) => u?.tools?.listChanged)) }
36
111
  : {}),
37
- ...(upstream?.resources
112
+ ...(upstreams.some((u) => u?.resources)
38
113
  ? {
39
- resources: upstream.resources.listChanged
40
- ? { listChanged: true }
41
- : {},
114
+ resources: listChanged(upstreams.some((u) => u?.resources?.listChanged)),
42
115
  }
43
116
  : {}),
44
- ...(upstream?.prompts
45
- ? { prompts: upstream.prompts.listChanged ? { listChanged: true } : {} }
117
+ ...(upstreams.some((u) => u?.prompts)
118
+ ? { prompts: listChanged(upstreams.some((u) => u?.prompts?.listChanged)) }
46
119
  : {}),
47
120
  };
48
121
  }
49
122
  function createRequestOptions(extra = {}) {
50
123
  const progressToken = extra._meta?.progressToken;
51
- const onprogress = progressToken && extra.sendNotification
124
+ // `0` and `''` are legitimate progress tokens — only undefined disables.
125
+ const onprogress = progressToken !== undefined && extra.sendNotification
52
126
  ? ({ progress, total, message, }) => {
53
- void extra.sendNotification?.({
127
+ // A client that disconnected mid-call makes sendNotification
128
+ // reject; dropping the progress tick is the correct outcome.
129
+ void extra
130
+ .sendNotification?.({
54
131
  method: 'notifications/progress',
55
132
  params: {
56
133
  progressToken,
@@ -58,7 +135,8 @@ function createRequestOptions(extra = {}) {
58
135
  ...(total === undefined ? {} : { total }),
59
136
  ...(message === undefined ? {} : { message }),
60
137
  },
61
- });
138
+ })
139
+ .catch(() => { });
62
140
  }
63
141
  : undefined;
64
142
  if (!extra.signal && !onprogress)
@@ -68,8 +146,22 @@ function createRequestOptions(extra = {}) {
68
146
  ...(onprogress ? { onprogress } : {}),
69
147
  };
70
148
  }
149
+ /**
150
+ * Credential-bearing headers are stripped before headers reach
151
+ * `ProxyContext` (and through it, audit logs). Identity resolution reads
152
+ * the raw `http.IncomingMessage`, so `resolveIdentity` still sees them.
153
+ */
154
+ const SENSITIVE_HEADERS = new Set([
155
+ 'authorization',
156
+ 'proxy-authorization',
157
+ 'cookie',
158
+ 'set-cookie',
159
+ 'x-api-key',
160
+ ]);
71
161
  function normalizeHeaders(headers) {
72
162
  const normalized = Object.entries(headers).reduce((acc, [key, value]) => {
163
+ if (SENSITIVE_HEADERS.has(key))
164
+ return acc;
73
165
  if (typeof value === 'string') {
74
166
  acc[key] = value;
75
167
  return acc;
@@ -81,143 +173,426 @@ function normalizeHeaders(headers) {
81
173
  }, {});
82
174
  return Object.keys(normalized).length ? normalized : undefined;
83
175
  }
84
- function getMiddlewareContext(signal) {
176
+ function getMiddlewareContext(proxy, signal, delegatedFrom) {
177
+ const store = httpProxyContext.getStore();
85
178
  return createProxyContext({
86
- ...httpProxyContext.getStore(),
179
+ ...store,
87
180
  ...(signal === undefined ? {} : { signal }),
181
+ // A host-stamped chain wins; an extracted one fills the field only when
182
+ // the host left it unset, which keeps the extraction additive (ADR-0016).
183
+ delegatedFrom: store?.delegatedFrom ?? delegatedFrom,
184
+ proxy,
88
185
  });
89
186
  }
90
- function filterHiddenTools(result, hiddenToolSet) {
91
- if (!hiddenToolSet.size)
92
- return result;
93
- return {
94
- ...result,
95
- tools: result.tools.filter((tool) => !hiddenToolSet.has(tool.name)),
96
- };
187
+ function getRejectionReason(err) {
188
+ const reason = err?.data
189
+ ?.rejectionReason;
190
+ // A reason outside the union is an ordinary failure, not a rejection (#238).
191
+ return isRejectionReason(reason) ? reason : undefined;
97
192
  }
98
- function registerListChangedForwarders(backend, server, capabilities) {
99
- if (!capabilities.tools?.listChanged &&
100
- !capabilities.resources?.listChanged &&
101
- !capabilities.prompts?.listChanged) {
102
- return;
193
+ /**
194
+ * Removes `params._meta` at the proxy boundary, before the pipeline runs,
195
+ * so middleware sees the stripped request, can still add its own `_meta`
196
+ * deliberately, and audit hashes cover what is actually forwarded.
197
+ * Identity function when there is nothing to strip.
198
+ */
199
+ function stripParamsMeta(req) {
200
+ if (req.params === undefined || !('_meta' in req.params))
201
+ return req;
202
+ const { _meta: _stripped, ...params } = req.params;
203
+ return { ...req, params };
204
+ }
205
+ /**
206
+ * Removes top-level `_meta` from an upstream result at the proxy boundary,
207
+ * inside the innermost `next`, so middleware sees the stripped result and
208
+ * middleware-added `_meta` still reaches the client. Nested `_meta`
209
+ * (per-tool in `tools[]`, per-block in `content`) is untouched.
210
+ * Identity function when there is nothing to strip.
211
+ */
212
+ function stripTopLevelMeta(res) {
213
+ if (!('_meta' in res))
214
+ return res;
215
+ const { _meta: _stripped, ...rest } = res;
216
+ // Omit<Res, '_meta'> still satisfies Res: `_meta` is optional.
217
+ return rest;
218
+ }
219
+ /**
220
+ * Validates and indexes `localTools`. Throws on a duplicate name: there is
221
+ * no correct way to pick one, and silently keeping the last would route
222
+ * calls to a handler the configuration does not obviously name.
223
+ */
224
+ /**
225
+ * Rejects a blank proxy name at construction, like a duplicate local tool name.
226
+ *
227
+ * The name is recorded as provenance on every `ProxyContext`, audit event, and
228
+ * `ReplayManifest` (ADR-0012), so a blank one produces a trail that looks
229
+ * attributed and is not (#122). The negated check covers all three bad values
230
+ * at once: whitespace-only trims to `''`, and `''` and an omitted name (from a
231
+ * JS caller the type does not bind) are falsy already.
232
+ */
233
+ function assertProxyName(name) {
234
+ if (!name?.trim()) {
235
+ throw new Error(`mcpose: name must be a non-empty string, got ${JSON.stringify(name)}`);
103
236
  }
104
- let bus = listChangedBuses.get(backend);
105
- if (!bus) {
106
- const servers = new Set();
107
- const fanOut = async (notify) => {
108
- await Promise.allSettled([...servers].map((proxyServer) => notify(proxyServer)));
109
- };
110
- if (capabilities.tools?.listChanged) {
111
- backend.setNotificationHandler(ToolListChangedNotificationSchema, () => fanOut((proxyServer) => proxyServer.sendToolListChanged()));
237
+ }
238
+ function buildLocalToolMap(localTools) {
239
+ const localToolMap = new Map();
240
+ for (const localTool of localTools ?? []) {
241
+ const toolName = localTool.tool.name;
242
+ if (localToolMap.has(toolName)) {
243
+ throw new Error(`mcpose: duplicate local tool name "${toolName}"`);
112
244
  }
113
- if (capabilities.prompts?.listChanged) {
114
- backend.setNotificationHandler(PromptListChangedNotificationSchema, () => fanOut((proxyServer) => proxyServer.sendPromptListChanged()));
245
+ localToolMap.set(toolName, localTool);
246
+ }
247
+ return localToolMap;
248
+ }
249
+ /**
250
+ * The backends advertising one surface, ready to list across and to route
251
+ * by backend key. Only these are queried or routed to, so an otherwise-valid
252
+ * prefix naming a backend without the surface is unroutable rather than a
253
+ * call the upstream cannot serve.
254
+ */
255
+ function surfaceBackends(entries, surface) {
256
+ const selected = entries.filter((entry) => entry.client.getServerCapabilities()?.[surface] !== undefined);
257
+ return {
258
+ entries: selected,
259
+ byKey: new Map(selected.map((entry) => [entry.key, entry.client])),
260
+ };
261
+ }
262
+ /** Normalizes the `hiddenTools` option to its predicate form. */
263
+ function toHiddenToolPredicate(hiddenTools) {
264
+ if (typeof hiddenTools === 'function')
265
+ return hiddenTools;
266
+ const hiddenToolSet = new Set(hiddenTools ?? []);
267
+ return (name) => hiddenToolSet.has(name);
268
+ }
269
+ function filterHiddenTools(result, isHiddenTool) {
270
+ // List phase: a listed tool has no arguments, so args is undefined.
271
+ const tools = result.tools.filter((tool) => !isHiddenTool(tool.name, undefined));
272
+ return tools.length === result.tools.length ? result : { ...result, tools };
273
+ }
274
+ /**
275
+ * Subscribes `server` to every backend's list-changed notifications, so a
276
+ * mesh fans them in through the same per-backend bus a 1:1 proxy uses.
277
+ *
278
+ * Every subscriber advertises the union of its backends' surfaces, so a
279
+ * notification a bus can emit is always one its subscribers accept, and the
280
+ * fan-out needs no per-surface filter.
281
+ */
282
+ function registerListChangedForwarders(entries, server, capabilities) {
283
+ const leaveBuses = [];
284
+ for (const { client } of entries) {
285
+ const upstream = client.getServerCapabilities() ?? {};
286
+ const surfaces = [
287
+ 'tools',
288
+ 'prompts',
289
+ 'resources',
290
+ ];
291
+ if (!surfaces.some((surface) => capabilities[surface]?.listChanged === true &&
292
+ upstream[surface]?.listChanged === true)) {
293
+ continue;
115
294
  }
116
- if (capabilities.resources?.listChanged) {
117
- backend.setNotificationHandler(ResourceListChangedNotificationSchema, () => fanOut((proxyServer) => proxyServer.sendResourceListChanged()));
295
+ let bus = listChangedBuses.get(client);
296
+ if (!bus) {
297
+ const servers = new Set();
298
+ const fanOut = async (notify) => {
299
+ await Promise.allSettled([...servers].map(notify));
300
+ };
301
+ if (upstream.tools?.listChanged) {
302
+ client.setNotificationHandler(ToolListChangedNotificationSchema, () => fanOut((proxyServer) => proxyServer.sendToolListChanged()));
303
+ }
304
+ if (upstream.prompts?.listChanged) {
305
+ client.setNotificationHandler(PromptListChangedNotificationSchema, () => fanOut((proxyServer) => proxyServer.sendPromptListChanged()));
306
+ }
307
+ if (upstream.resources?.listChanged) {
308
+ client.setNotificationHandler(ResourceListChangedNotificationSchema, () => fanOut((proxyServer) => proxyServer.sendResourceListChanged()));
309
+ }
310
+ bus = { servers };
311
+ listChangedBuses.set(client, bus);
118
312
  }
119
- bus = { servers };
120
- listChangedBuses.set(backend, bus);
313
+ const joined = bus;
314
+ joined.servers.add(server);
315
+ let active = true;
316
+ leaveBuses.push(() => {
317
+ if (!active)
318
+ return;
319
+ active = false;
320
+ joined.servers.delete(server);
321
+ if (!joined.servers.size)
322
+ listChangedBuses.delete(client);
323
+ });
121
324
  }
122
- bus.servers.add(server);
123
- const prevOnClose = server.onclose;
124
- let active = true;
125
- server.onclose = () => {
126
- if (!active)
127
- return;
128
- active = false;
129
- bus.servers.delete(server);
130
- if (!bus.servers.size)
131
- listChangedBuses.delete(backend);
132
- prevOnClose?.();
133
- };
325
+ if (leaveBuses.length === 0)
326
+ return;
327
+ // Accessor instead of plain assignment: a consumer setting `server.onclose`
328
+ // after createProxyServer() must not clobber the bus cleanup (which would
329
+ // leak this server into the fan-out set forever).
330
+ let consumerOnClose;
331
+ Object.defineProperty(server, 'onclose', {
332
+ configurable: true,
333
+ get: () => () => {
334
+ for (const leaveBus of leaveBuses)
335
+ leaveBus();
336
+ consumerOnClose?.();
337
+ },
338
+ set: (fn) => {
339
+ consumerOnClose = fn;
340
+ },
341
+ });
134
342
  }
135
343
  /**
136
344
  * Creates a proxy MCP server without connecting it to a transport.
137
345
  *
138
346
  * Mirrors upstream tool/resource/prompt lists and routes requests through
139
- * middleware pipelines. Prompts are forwarded as-is.
347
+ * middleware pipelines. `prompts/list` is forwarded as-is; `prompts/get`
348
+ * runs `promptMiddleware` (ADR-0014).
140
349
  *
141
350
  * Uses low-level `Server` (not `McpServer`) — transparent proxying requires
142
351
  * generic list interception; `McpServer.tool()` needs names upfront.
143
352
  *
144
- * @param backend - Connected (or mock) upstream MCP client.
353
+ * @param backends - Connected (or mock) upstream MCP client for a 1:1 proxy,
354
+ * or a record of named backends for mesh mode (ADR-0013), where every
355
+ * upstream tool and prompt is exposed as `<backendKey>__<name>`.
145
356
  * @param options - Middleware stacks, hidden/passthrough sets.
146
357
  * @returns Configured {@link Server} ready to connect.
147
358
  */
148
- export function createProxyServer(backend, options = {}) {
149
- const capabilities = createProxyCapabilities(backend);
359
+ export function createProxyServer(backends, options) {
360
+ assertProxyName(options.name);
361
+ const { mesh, entries } = normalizeBackends(backends);
362
+ for (const { key, client } of entries) {
363
+ if (client.getServerCapabilities() === undefined) {
364
+ throw new Error(`mcpose: backend${mesh ? ` "${key}"` : ''} is not connected (getServerCapabilities() returned undefined). Connect the backend before calling createProxyServer().`);
365
+ }
366
+ }
367
+ const backend = entries[0].client;
368
+ const localToolMap = buildLocalToolMap(options.localTools);
369
+ const toolBackends = surfaceBackends(entries, 'tools');
370
+ const promptBackends = surfaceBackends(entries, 'prompts');
371
+ const resourceBackends = surfaceBackends(entries, 'resources');
372
+ const upstreamHasTools = toolBackends.entries.length > 0;
373
+ const capabilities = createProxyCapabilities(entries, localToolMap.size > 0);
150
374
  const toolPipeline = pipe(options.toolMiddleware ?? []);
375
+ // Pass-through tools skip transforming middleware but are still seen by
376
+ // observers (audit, telemetry) marked via markPassThroughObserver().
377
+ const passThroughToolPipeline = pipe((options.toolMiddleware ?? []).filter(isPassThroughObserver));
151
378
  const resourcePipeline = pipe(options.resourceMiddleware ?? []);
379
+ const promptPipeline = pipe(options.promptMiddleware ?? []);
152
380
  const listToolsPipeline = pipe(options.listToolsMiddleware ?? []);
153
- const hiddenToolSet = new Set(options.hiddenTools ?? []);
381
+ const isHiddenTool = toHiddenToolPredicate(options.hiddenTools);
382
+ // Applied uniformly at every handler entry, pass-through included: a
383
+ // privacy control a per-tool option could silently switch off would be
384
+ // the same false-confidence failure as the dispatcher bypass (ADR-0006).
385
+ const stripMeta = options.stripRequestMeta !== false;
386
+ const stripRequest = (req) => (stripMeta ? stripParamsMeta(req) : req);
387
+ // The response-direction mirror: applied to every upstream result at the
388
+ // innermost `next`, never to local tool results (they have no upstream).
389
+ const stripResMeta = options.stripResultMeta !== false;
390
+ const stripResult = (res) => stripResMeta ? stripTopLevelMeta(res) : res;
154
391
  const passThroughToolSet = new Set(options.passThroughTools ?? []);
155
392
  const hiddenResourceSet = new Set(options.hiddenResources ?? []);
156
393
  const passThroughResourceSet = new Set(options.passThroughResources ?? []);
157
- const server = new Server({
158
- name: options.name ?? DEFAULT_PROXY_NAME,
159
- version: options.version ?? pkg.version,
160
- }, { capabilities });
161
- registerListChangedForwarders(backend, server, capabilities);
394
+ // Stamped onto every ProxyContext so middleware and the audit trail can
395
+ // attribute events to this proxy instance (#85, ADR-0012). Frozen because
396
+ // the one object is aliased into the SDK Server, every context, and every
397
+ // audit event: a middleware mutating it would rewrite provenance already
398
+ // recorded elsewhere.
399
+ const proxyIdentity = Object.freeze({
400
+ name: options.name,
401
+ version: options.version ?? VERSION,
402
+ });
403
+ const server = new Server(proxyIdentity, { capabilities });
404
+ registerListChangedForwarders(entries, server, capabilities);
405
+ /**
406
+ * Reports a backend that dropped out of a mesh list. Failure isolation is
407
+ * only safe if the gap is visible, and a throwing sink must not turn a
408
+ * degraded list into a failed one.
409
+ */
410
+ const reportDegraded = (context, backendKey, method, error) => {
411
+ callTelemetrySink(options.onTelemetry, {
412
+ type: 'backend_degraded',
413
+ requestId: context.requestId,
414
+ ...(context.sessionId === undefined
415
+ ? {}
416
+ : { sessionId: context.sessionId }),
417
+ backend: backendKey,
418
+ method,
419
+ error,
420
+ ...(context.identity === undefined ? {} : { identity: context.identity }),
421
+ ...(context.proxy === undefined ? {} : { proxy: context.proxy }),
422
+ });
423
+ };
162
424
  // ── Tool handlers ──────────────────────────────────────────────────────────
163
425
  if (capabilities.tools) {
164
- server.setRequestHandler(ListToolsRequestSchema, async (req, extra) => {
426
+ server.setRequestHandler(ListToolsRequestSchema, async (rawReq, extra) => {
427
+ // Read the chain before the strip, or it never survives a chained
428
+ // proxy (ADR-0016, the ADR-0011 boundary-ordering constraint).
429
+ const { chain, error: wireError } = readInboundDelegation(rawReq);
430
+ const req = stripRequest(rawReq);
165
431
  const requestOptions = createRequestOptions(extra);
166
- const context = getMiddlewareContext(extra.signal);
167
- const result = await listToolsPipeline(req, async (currentReq) => filterHiddenTools(await backend.listTools(currentReq.params, requestOptions), hiddenToolSet), context);
168
- return filterHiddenTools(result, hiddenToolSet);
432
+ const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
433
+ const delegationError = wireError ?? detectDelegationLoop(context);
434
+ // Local tools are overlaid inside the innermost `next`, so
435
+ // listToolsMiddleware sees them exactly as it sees upstream tools.
436
+ // They are added to the first page only (no cursor), so a paginating
437
+ // client sees each local tool once; the shadow filter runs on every
438
+ // page, so the client sees exactly one entry per name.
439
+ const result = await listToolsPipeline(req, async (currentReq) => {
440
+ // Thrown inside the pipeline, so observing middleware records the
441
+ // attempt rather than seeing a call that never happened.
442
+ if (delegationError !== undefined)
443
+ throw delegationError;
444
+ const upstreamResult = mesh
445
+ ? {
446
+ // A mesh has no cursor that means "page 3 of the union", so
447
+ // every backend is drained into one complete page.
448
+ tools: await listAcrossMesh(toolBackends.entries, async (client, cursor) => {
449
+ const page = stripResult(await client.listTools(attachDelegationMeta(cursor === undefined ? {} : { cursor }, context), requestOptions));
450
+ return { items: page.tools, nextCursor: page.nextCursor };
451
+ }, (key, error) => reportDegraded(context, key, 'tools/list', error), namespaceName),
452
+ }
453
+ : upstreamHasTools
454
+ ? stripResult(await backend.listTools(attachDelegationMeta(currentReq.params, context), requestOptions))
455
+ : { tools: [] };
456
+ if (!localToolMap.size) {
457
+ return filterHiddenTools(upstreamResult, isHiddenTool);
458
+ }
459
+ const tools = [
460
+ ...upstreamResult.tools.filter((t) => !localToolMap.has(t.name)),
461
+ // A mesh response is always the only page, so local tools belong
462
+ // on it whatever cursor the client sent.
463
+ ...(mesh || currentReq.params?.cursor === undefined
464
+ ? [...localToolMap.values()].map((lt) => lt.tool)
465
+ : []),
466
+ ];
467
+ return filterHiddenTools({ ...upstreamResult, tools }, isHiddenTool);
468
+ }, context);
469
+ return filterHiddenTools(result, isHiddenTool);
169
470
  });
170
- server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
471
+ server.setRequestHandler(CallToolRequestSchema, async (rawReq, extra) => {
472
+ const { chain, error: wireError } = readInboundDelegation(rawReq);
473
+ const req = stripRequest(rawReq);
171
474
  const name = req.params.name;
172
475
  const requestOptions = createRequestOptions(extra);
173
- const context = getMiddlewareContext(extra.signal);
174
- const start = Date.now();
476
+ const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
477
+ const delegationError = wireError ?? detectDelegationLoop(context);
478
+ const start = performance.now();
175
479
  const emitTelemetry = (outcome, rejectionReason) => {
176
- options.onTelemetry?.({
480
+ callTelemetrySink(options.onTelemetry, {
177
481
  type: 'tool_call',
178
482
  requestId: context.requestId,
179
483
  ...(context.sessionId === undefined
180
484
  ? {}
181
485
  : { sessionId: context.sessionId }),
182
486
  tool: name,
183
- duration_ms: Date.now() - start,
487
+ duration_ms: Math.round(performance.now() - start),
184
488
  outcome,
185
489
  ...(rejectionReason === undefined ? {} : { rejectionReason }),
186
490
  ...(context.identity === undefined
187
491
  ? {}
188
492
  : { identity: context.identity }),
493
+ ...(context.proxy === undefined ? {} : { proxy: context.proxy }),
189
494
  });
190
495
  };
191
- if (hiddenToolSet.has(name)) {
192
- emitTelemetry('rejected', 'TOOL_HIDDEN');
193
- throw rejectionMcpError('TOOL_HIDDEN', ErrorCode.MethodNotFound, `Tool not found: ${name}`);
194
- }
195
- if (passThroughToolSet.has(name)) {
196
- try {
197
- const result = await backend.callTool(req.params, undefined, requestOptions);
198
- emitTelemetry('success');
199
- return result;
496
+ // Hidden beats pass-through. The rejection is thrown by the innermost
497
+ // `next` INSIDE the pipeline so middleware (audit) observes it in-chain;
498
+ // the backend is never called for hidden tools.
499
+ // Call phase: args is always an object, empty when the client sent
500
+ // none, so a dispatcher-aware predicate can fail closed on it.
501
+ const isHidden = isHiddenTool(name, req.params.arguments ?? {});
502
+ // Hidden beats local; local beats upstream; pass-through never
503
+ // applies to a local tool (it has no upstream response to forward).
504
+ const localTool = isHidden ? undefined : localToolMap.get(name);
505
+ const pipeline = !isHidden && localTool === undefined && passThroughToolSet.has(name)
506
+ ? passThroughToolPipeline
507
+ : toolPipeline;
508
+ // The return type is annotated on the binding rather than on the arrow:
509
+ // Babel, which Stryker uses to instrument this file for mutation testing,
510
+ // cannot parse an async arrow carrying a return type inside a conditional.
511
+ const callBackend = isHidden
512
+ ? async () => {
513
+ throw rejectionMcpError('TOOL_HIDDEN', ErrorCode.MethodNotFound, `Tool not found: ${name}`);
200
514
  }
201
- catch (err) {
202
- emitTelemetry('error');
203
- throw err;
204
- }
205
- }
515
+ : localTool !== undefined
516
+ ? (r) => localTool.handler(r.params, context)
517
+ : mesh
518
+ ? async (r) => {
519
+ // Routed at the innermost `next` from the post-pipeline name,
520
+ // so middleware can re-route a call deliberately.
521
+ const routed = routeNamespaced(r.params.name, toolBackends.byKey);
522
+ if (routed === undefined) {
523
+ throw rejectionMcpError('BACKEND_UNROUTABLE', ErrorCode.MethodNotFound, `Tool not found: ${r.params.name} — a mesh exposes tools as "<backendKey>__<tool>"`);
524
+ }
525
+ return stripResult(await routed.client.callTool(attachDelegationMeta({ ...r.params, name: routed.name }, context), undefined, requestOptions));
526
+ }
527
+ : upstreamHasTools
528
+ ? async (r) => stripResult(await backend.callTool(attachDelegationMeta(r.params, context), undefined, requestOptions))
529
+ : async () => {
530
+ // Tools-less upstream: reject inside the pipeline rather
531
+ // than forwarding to an upstream that cannot serve it.
532
+ throw new McpError(ErrorCode.MethodNotFound, `Tool not found: ${name}`);
533
+ };
206
534
  try {
207
- const result = await toolPipeline(req, (r) => backend.callTool(r.params, undefined, requestOptions), context);
208
- emitTelemetry('success');
535
+ // A malformed chain replaces the innermost `next` rather than
536
+ // rejecting at the boundary, so the pipeline observes it, and it is
537
+ // decided before the tool is: the answer says nothing about the tool.
538
+ const result = await pipeline(req, delegationError === undefined
539
+ ? callBackend
540
+ : () => Promise.reject(delegationError), context);
541
+ // MCP signals tool-level failures in-band via isError, not by throwing.
542
+ // Three shapes reach here, and `hasToolContent` alone cannot tell them
543
+ // apart because it answers false for two of them:
544
+ //
545
+ // - a CallToolResult: judge it by `isError`;
546
+ // - the legacy `{ toolResult }` shape (protocol 2024-10-07): a valid
547
+ // result with no `.content`, so `mapToolResult` passes it through —
548
+ // a success;
549
+ // - nothing at all, from a local tool with a missing `return` or a
550
+ // middleware that dropped the result: the call produced no result,
551
+ // so it is not a success.
552
+ //
553
+ // A result-less call used to reach the `catch` as a TypeError from
554
+ // `hasToolContent` and be recorded as an error; now that the guard
555
+ // answers false instead of throwing, the shape has to be distinguished
556
+ // here or a `&&` would report it as a clean one.
557
+ const isResultless = result === null || result === undefined || typeof result !== 'object';
558
+ emitTelemetry(isResultless
559
+ ? 'error'
560
+ : hasToolContent(result) && result.isError === true
561
+ ? 'error'
562
+ : 'success');
209
563
  return result;
210
564
  }
211
565
  catch (err) {
212
- emitTelemetry('error');
566
+ const reason = getRejectionReason(err);
567
+ if (reason === undefined)
568
+ emitTelemetry('error');
569
+ else
570
+ emitTelemetry('rejected', reason);
213
571
  throw err;
214
572
  }
215
573
  });
216
574
  }
217
575
  // ── Resource handlers ──────────────────────────────────────────────────────
218
576
  if (capabilities.resources) {
219
- server.setRequestHandler(ListResourcesRequestSchema, async (req, extra) => {
220
- const result = await backend.listResources(req.params, createRequestOptions(extra));
577
+ server.setRequestHandler(ListResourcesRequestSchema, async (rawReq, extra) => {
578
+ const { chain, error: wireError } = readInboundDelegation(rawReq);
579
+ const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
580
+ const delegationError = wireError ?? detectDelegationLoop(context);
581
+ // `resources/list` runs no pipeline, so there is no observer to
582
+ // record the attempt: the rejection is thrown here instead.
583
+ if (delegationError !== undefined)
584
+ throw delegationError;
585
+ const requestOptions = createRequestOptions(extra);
586
+ const result = mesh
587
+ ? {
588
+ // Each URI is exposed as `mcpose://<key>/<uri>` (ADR-0022), so
589
+ // two backends serving the same URI never collide.
590
+ resources: await listAcrossMesh(resourceBackends.entries, async (client, cursor) => {
591
+ const page = stripResult(await client.listResources(attachDelegationMeta(cursor === undefined ? {} : { cursor }, context), requestOptions));
592
+ return { items: page.resources, nextCursor: page.nextCursor };
593
+ }, (key, error) => reportDegraded(context, key, 'resources/list', error), namespaceUri),
594
+ }
595
+ : stripResult(await backend.listResources(attachDelegationMeta(stripRequest(rawReq).params, context), requestOptions));
221
596
  if (!hiddenResourceSet.size)
222
597
  return result;
223
598
  return {
@@ -225,23 +600,107 @@ export function createProxyServer(backend, options = {}) {
225
600
  resources: result.resources.filter((r) => !hiddenResourceSet.has(r.uri)),
226
601
  };
227
602
  });
228
- server.setRequestHandler(ReadResourceRequestSchema, (req, extra) => {
603
+ server.setRequestHandler(ReadResourceRequestSchema, (rawReq, extra) => {
604
+ const { chain, error: wireError } = readInboundDelegation(rawReq);
605
+ const req = stripRequest(rawReq);
229
606
  const uri = req.params.uri;
230
607
  const requestOptions = createRequestOptions(extra);
231
- const context = getMiddlewareContext(extra.signal);
608
+ const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
609
+ const delegationError = wireError ?? detectDelegationLoop(context);
232
610
  if (hiddenResourceSet.has(uri)) {
233
611
  throw rejectionMcpError('RESOURCE_HIDDEN', ErrorCode.InvalidRequest, `Resource not found: ${uri}`);
234
612
  }
613
+ // In mesh mode the exposed URI is unwrapped to a backend and its own
614
+ // URI; an unwrappable one is rejected loudly, with no lookup of which
615
+ // backend listed it (ADR-0022). Resolved from the post-pipeline params
616
+ // so middleware can re-route a read deliberately.
617
+ const readUpstream = (params) => {
618
+ if (delegationError !== undefined)
619
+ throw delegationError;
620
+ if (!mesh) {
621
+ return backend
622
+ .readResource(attachDelegationMeta(params, context), requestOptions)
623
+ .then(stripResult);
624
+ }
625
+ const routed = routeResourceUri(params.uri, resourceBackends.byKey);
626
+ if (routed === undefined) {
627
+ throw rejectionMcpError('BACKEND_UNROUTABLE', ErrorCode.InvalidRequest, `Resource not found: ${params.uri} — a mesh exposes resources as "mcpose://<backendKey>/<uri>"`);
628
+ }
629
+ return routed.client
630
+ .readResource(attachDelegationMeta({ ...params, uri: routed.uri }, context), requestOptions)
631
+ .then(stripResult);
632
+ };
235
633
  if (passThroughResourceSet.has(uri)) {
236
- return backend.readResource(req.params, requestOptions);
634
+ // A pass-through resource runs no pipeline at all, so as with
635
+ // `resources/list` the rejection is thrown here.
636
+ return readUpstream(req.params);
237
637
  }
238
- return resourcePipeline(req, (r) => backend.readResource(r.params, requestOptions), context);
638
+ return resourcePipeline(req, (r) => readUpstream(r.params), context);
239
639
  });
240
640
  }
241
- // ── Prompt handlers (pass-through) ────────────────────────────────────────
242
- if (capabilities.prompts) {
243
- server.setRequestHandler(ListPromptsRequestSchema, (req, extra) => backend.listPrompts(req.params, createRequestOptions(extra)));
244
- server.setRequestHandler(GetPromptRequestSchema, (req, extra) => backend.getPrompt(req.params, createRequestOptions(extra)));
641
+ // ── Prompt handlers ───────────────────────────────────────────────────────
642
+ // `prompts/get` runs the promptMiddleware pipeline; `prompts/list` is still
643
+ // pass-through (ADR-0014).
644
+ if (capabilities.prompts && mesh) {
645
+ // Prompt names are plain strings, so they namespace and route exactly
646
+ // like tool names (ADR-0013).
647
+ server.setRequestHandler(ListPromptsRequestSchema, async (rawReq, extra) => {
648
+ const { chain, error: wireError } = readInboundDelegation(rawReq);
649
+ const requestOptions = createRequestOptions(extra);
650
+ const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
651
+ // No pipeline here either, so the rejection is thrown at the handler.
652
+ const delegationError = wireError ?? detectDelegationLoop(context);
653
+ if (delegationError !== undefined)
654
+ throw delegationError;
655
+ return {
656
+ prompts: await listAcrossMesh(promptBackends.entries, async (client, cursor) => {
657
+ const page = stripResult(await client.listPrompts(attachDelegationMeta(cursor === undefined ? {} : { cursor }, context), requestOptions));
658
+ return { items: page.prompts, nextCursor: page.nextCursor };
659
+ }, (key, error) => reportDegraded(context, key, 'prompts/list', error), namespaceName),
660
+ };
661
+ });
662
+ server.setRequestHandler(GetPromptRequestSchema, (rawReq, extra) => {
663
+ const { chain, error: wireError } = readInboundDelegation(rawReq);
664
+ const req = stripRequest(rawReq);
665
+ const requestOptions = createRequestOptions(extra);
666
+ const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
667
+ const delegationError = wireError ?? detectDelegationLoop(context);
668
+ // Routed at the innermost `next` from the post-pipeline name, so the
669
+ // rejection is thrown in-chain and middleware (audit) observes it.
670
+ return promptPipeline(req, async (r) => {
671
+ if (delegationError !== undefined)
672
+ throw delegationError;
673
+ const routed = routeNamespaced(r.params.name, promptBackends.byKey);
674
+ if (routed === undefined) {
675
+ throw rejectionMcpError('BACKEND_UNROUTABLE', ErrorCode.MethodNotFound, `Prompt not found: ${r.params.name} — a mesh exposes prompts as "<backendKey>__<prompt>"`);
676
+ }
677
+ return stripResult(await routed.client.getPrompt(attachDelegationMeta({ ...r.params, name: routed.name }, context), requestOptions));
678
+ }, context);
679
+ });
680
+ }
681
+ else if (capabilities.prompts) {
682
+ server.setRequestHandler(ListPromptsRequestSchema, (rawReq, extra) => {
683
+ const { chain, error: wireError } = readInboundDelegation(rawReq);
684
+ const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
685
+ // No pipeline here either, so the rejection is thrown at the handler.
686
+ const delegationError = wireError ?? detectDelegationLoop(context);
687
+ if (delegationError !== undefined)
688
+ throw delegationError;
689
+ return backend
690
+ .listPrompts(attachDelegationMeta(stripRequest(rawReq).params, context), createRequestOptions(extra))
691
+ .then(stripResult);
692
+ });
693
+ server.setRequestHandler(GetPromptRequestSchema, (rawReq, extra) => {
694
+ const { chain, error: wireError } = readInboundDelegation(rawReq);
695
+ const requestOptions = createRequestOptions(extra);
696
+ const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
697
+ const delegationError = wireError ?? detectDelegationLoop(context);
698
+ return promptPipeline(stripRequest(rawReq), async (r) => {
699
+ if (delegationError !== undefined)
700
+ throw delegationError;
701
+ return stripResult(await backend.getPrompt(attachDelegationMeta(r.params, context), requestOptions));
702
+ }, context);
703
+ });
245
704
  }
246
705
  return server;
247
706
  }
@@ -250,31 +709,77 @@ export function createProxyServer(backend, options = {}) {
250
709
  * Calls {@link createProxyServer} then connects to `StdioServerTransport`.
251
710
  * Use `createProxyServer` directly for testable access to the server.
252
711
  */
253
- export async function startProxy(backend, options = {}) {
254
- const server = createProxyServer(backend, options);
712
+ export async function startProxy(backends, options) {
713
+ const server = createProxyServer(backends, options);
255
714
  await server.connect(new StdioServerTransport());
256
715
  }
716
+ /**
717
+ * Enforces `maxBodyBytes`.
718
+ *
719
+ * Fast path: a declared `Content-Length` over the limit is rejected before
720
+ * any body is read. Chunked/streamed bodies are counted as they arrive.
721
+ *
722
+ * @returns `true` when the request was already rejected (caller must stop).
723
+ */
257
724
  function applyBodySizeLimit(req, res, maxBodyBytes) {
725
+ const reject413 = () => {
726
+ if (!res.headersSent) {
727
+ res.writeHead(413, { 'content-type': 'application/json' }).end(JSON.stringify({
728
+ error: {
729
+ message: 'Request body too large',
730
+ data: { rejectionReason: 'BODY_LIMIT' },
731
+ },
732
+ }));
733
+ }
734
+ };
735
+ const declared = Number(req.headers['content-length']);
736
+ if (Number.isFinite(declared) && declared > maxBodyBytes) {
737
+ reject413();
738
+ req.destroy();
739
+ return true;
740
+ }
258
741
  let total = 0;
259
742
  const originalPush = req.push.bind(req);
260
743
  req.push = (chunk, enc) => {
261
- if (chunk !== null && total + chunk.length > maxBodyBytes) {
262
- if (!res.headersSent)
263
- res.writeHead(413).end();
264
- // Mute the response for downstream writers (the SDK transport will
265
- // observe the destroyed request and try to send its own error).
266
- const muted = res;
267
- muted.writeHead = () => res;
268
- muted.write = () => true;
269
- muted.end = () => res;
744
+ const chunkBytes = chunk === null
745
+ ? 0
746
+ : typeof chunk === 'string'
747
+ ? Buffer.byteLength(chunk, enc)
748
+ : chunk.length;
749
+ if (chunk !== null && total + chunkBytes > maxBodyBytes) {
750
+ reject413();
751
+ if (res.headersSent) {
752
+ // Too late for a clean 413 — sever the connection instead of
753
+ // leaving the client on a hung socket.
754
+ res.destroy();
755
+ }
756
+ else {
757
+ // Mute the response for downstream writers (the SDK transport will
758
+ // observe the destroyed request and try to send its own error).
759
+ const muted = res;
760
+ muted.writeHead = () => res;
761
+ muted.write = () => true;
762
+ muted.end = () => res;
763
+ }
270
764
  req.destroy(new Error('Request body too large'));
271
765
  return false;
272
766
  }
273
- if (chunk !== null)
274
- total += chunk.length;
767
+ total += chunkBytes;
275
768
  return originalPush(chunk, enc);
276
769
  };
770
+ return false;
771
+ }
772
+ /**
773
+ * True for bind addresses only reachable from the local machine.
774
+ * `127.` catches the whole 127.0.0.0/8 block Node can bind.
775
+ */
776
+ function isLoopbackHost(host) {
777
+ return (host === 'localhost' ||
778
+ host === '::1' ||
779
+ host === '::ffff:127.0.0.1' ||
780
+ host.startsWith('127.'));
277
781
  }
782
+ const MAX_NODE_TIMER_DELAY_MS = 2 ** 31 - 1;
278
783
  /**
279
784
  * Starts the proxy over Streamable HTTP with stateful sessions.
280
785
  *
@@ -286,15 +791,215 @@ function applyBodySizeLimit(req, res, maxBodyBytes) {
286
791
  * @returns Promise resolving to the listening `http.Server` (or `https.Server`
287
792
  * when `tlsOptions` is supplied).
288
793
  */
289
- export function startHttpProxy(backend, options = {}, httpOptions = {}) {
794
+ export function startHttpProxy(backends, options, httpOptions = {}) {
795
+ // Sessions build their proxy server lazily, so surface configuration
796
+ // errors (a blank name, duplicate local tool names, invalid backend keys)
797
+ // at startup, not on first initialize.
798
+ assertProxyName(options.name);
799
+ buildLocalToolMap(options.localTools);
800
+ normalizeBackends(backends);
290
801
  const mcpPath = httpOptions.path ?? '/mcp';
291
802
  const port = httpOptions.port ?? 3000;
292
- const host = httpOptions.host;
803
+ const host = httpOptions.host ?? '127.0.0.1';
804
+ const loopback = isLoopbackHost(host);
805
+ const dnsRebindingProtection = httpOptions.enableDnsRebindingProtection ?? loopback;
806
+ // Bounded by default (#107): unlimited sessions that never expire are a
807
+ // memory exhaustion path for any client that can initialize. `Infinity`
808
+ // is the opt-out for both; `maxSessions: 0` already means "reject every
809
+ // session", so it cannot double as one. The `!(x >= n)` form also
810
+ // rejects NaN, which a plain `x < n` would let through.
811
+ const maxSessions = httpOptions.maxSessions ?? 1000;
812
+ const sessionTtlMs = httpOptions.sessionTtlMs ?? 30 * 60 * 1000;
813
+ if (!(maxSessions >= 0)) {
814
+ throw new Error(`mcpose: maxSessions must be >= 0 or Infinity, got ${String(httpOptions.maxSessions)}`);
815
+ }
816
+ if (!(sessionTtlMs > 0)) {
817
+ throw new Error(`mcpose: sessionTtlMs must be > 0 or Infinity, got ${String(httpOptions.sessionTtlMs)}`);
818
+ }
819
+ if (Number.isFinite(sessionTtlMs) && sessionTtlMs > MAX_NODE_TIMER_DELAY_MS) {
820
+ throw new Error(`mcpose: sessionTtlMs must be <= ${MAX_NODE_TIMER_DELAY_MS} or Infinity, got ${String(httpOptions.sessionTtlMs)}`);
821
+ }
822
+ // Resolved once, like the two limits above, so a bad value fails at startup
823
+ // instead of quietly gating nothing. A `NaN` cap compares false against both
824
+ // the declared `Content-Length` and the running byte count, so a body far
825
+ // larger than the configured limit passes this layer untouched.
826
+ // `Number(process.env.MAX_BODY_BYTES)` is the ordinary way a host wires an
827
+ // operator-supplied limit in, and it is NaN when the key is unset or
828
+ // misspelled; #264 rejected a non-finite `budget.maxCallsPerSession` for
829
+ // exactly this reason. `Infinity` opts out, and `0`, like `maxSessions: 0`,
830
+ // keeps its literal meaning instead of doubling as one.
831
+ const maxBodyBytes = httpOptions.maxBodyBytes ?? 4 * 1024 * 1024;
832
+ if (!(maxBodyBytes >= 0)) {
833
+ throw new Error(`mcpose: maxBodyBytes must be >= 0 or Infinity, got ${String(httpOptions.maxBodyBytes)}`);
834
+ }
835
+ // Filled in once the server is listening (the real port is only known
836
+ // then); sessions are only created after that. The SDK transport
837
+ // validates Host/Origin only against a non-empty list, so an enabled flag
838
+ // without a list would be an inert control — see #74.
839
+ let derivedAllowedHosts;
840
+ let derivedAllowedOrigins;
293
841
  const eventStore = httpOptions.eventStore === null
294
842
  ? undefined
295
843
  : (httpOptions.eventStore ?? createInMemoryEventStore());
296
- // session ID → { transport, proxyServer, identity }
844
+ const registry = httpOptions.sessionRegistry;
845
+ // session ID → live session held by this instance
297
846
  const sessions = new Map();
847
+ // Sessions being initialized or resumed (identity resolution or registry
848
+ // lookup in flight) — counted so concurrent admissions cannot overshoot
849
+ // maxSessions.
850
+ let pendingSessions = 0;
851
+ const reportError = (err) => {
852
+ // `onError` is observability, so its own failure must change nothing, the
853
+ // same containment `onAuditError` applies to `@mcpose/audit`'s reporter
854
+ // (#172) and `createConsentMiddleware` applies to its resolver hook. Left
855
+ // unguarded, a throwing reporter replaced the 401 this file documents for
856
+ // a failed auth hook with a 500, aborted `destroySession` before it closed
857
+ // the proxy server, and threw out of the `server.on('error')` handler
858
+ // that exists precisely so a post-listen error does not crash the process.
859
+ // An async reporter that rejects after its first await escapes the same
860
+ // guard one tick later (#245): nobody observes the rejection, and Node's
861
+ // default --unhandled-rejections=throw ends the process, so the returned
862
+ // promise is adopted here too.
863
+ try {
864
+ Promise.resolve((httpOptions.onError ?? console.error)(err)).catch(() => {
865
+ // Nothing left to report it to.
866
+ });
867
+ }
868
+ catch {
869
+ // Nothing left to report it to.
870
+ }
871
+ };
872
+ /**
873
+ * Single teardown path for every way a session can end: client DELETE,
874
+ * TTL expiry, and server shutdown. Clears the TTL timer, drops the shared
875
+ * record unless the session is meant to outlive this process, fires and
876
+ * awaits `onSessionClosed` (guarded — a throwing or rejecting hook must not
877
+ * break teardown), and closes the proxy server so it leaves the listChanged
878
+ * fan-out bus. Idempotent: a second call for the same id is a no-op.
879
+ */
880
+ const destroySession = async (id, keepRecord = false) => {
881
+ const session = sessions.get(id);
882
+ if (!session)
883
+ return;
884
+ sessions.delete(id);
885
+ if (session.ttlTimer !== undefined)
886
+ clearTimeout(session.ttlTimer);
887
+ if (registry && !keepRecord)
888
+ await registry.delete(id).catch(reportError);
889
+ try {
890
+ await httpOptions.onSessionClosed?.(id);
891
+ }
892
+ catch (err) {
893
+ reportError(err);
894
+ }
895
+ await session.proxyServer.close().catch(reportError);
896
+ };
897
+ /**
898
+ * Takes a session live on this instance. `expiresAt` is the session's
899
+ * deadline fixed at creation, so a resumed session gets the remaining
900
+ * lifetime rather than a fresh TTL; `undefined` means it never expires.
901
+ */
902
+ const admitSession = (id, transport, proxyServer, identity, expiresAt) => {
903
+ let ttlTimer;
904
+ if (expiresAt !== undefined) {
905
+ // A record from a proxy with a longer TTL may still exceed Node's
906
+ // timer ceiling; clamping only makes that timer fire early and
907
+ // re-check nothing, since the session is then simply closed.
908
+ const remaining = Math.min(Math.max(expiresAt - Date.now(), 0), MAX_NODE_TIMER_DELAY_MS);
909
+ ttlTimer = setTimeout(() => {
910
+ void destroySession(id);
911
+ }, remaining);
912
+ ttlTimer.unref();
913
+ }
914
+ sessions.set(id, {
915
+ transport,
916
+ proxyServer,
917
+ ...(identity === undefined ? {} : { identity }),
918
+ ...(ttlTimer === undefined ? {} : { ttlTimer }),
919
+ });
920
+ };
921
+ const sessionLimitBody = JSON.stringify({
922
+ error: {
923
+ message: 'Session limit reached',
924
+ data: { rejectionReason: 'SESSION_LIMIT' },
925
+ },
926
+ });
927
+ // Builds the transport for one session. The id is fixed before the
928
+ // transport exists so the event store can be scoped to it: stream ids are
929
+ // namespaced per session (#154).
930
+ const createTransport = (sessionId, onsessioninitialized) => {
931
+ // An explicit list is used verbatim; the derived loopback list only
932
+ // fills the gap so the default actually validates something.
933
+ const allowedHosts = httpOptions.allowedHosts ?? derivedAllowedHosts;
934
+ const allowedOrigins = httpOptions.allowedOrigins ?? derivedAllowedOrigins;
935
+ return new StreamableHTTPServerTransport({
936
+ sessionIdGenerator: () => sessionId,
937
+ ...(eventStore
938
+ ? { eventStore: scopeEventStore(eventStore, sessionId) }
939
+ : {}),
940
+ ...(allowedHosts ? { allowedHosts } : {}),
941
+ ...(allowedOrigins ? { allowedOrigins } : {}),
942
+ enableDnsRebindingProtection: dnsRebindingProtection,
943
+ onsessioninitialized,
944
+ onsessionclosed: (id) => {
945
+ void destroySession(id);
946
+ },
947
+ });
948
+ };
949
+ // Resumes in flight, so concurrent requests for one unknown id build one
950
+ // session between them instead of one each.
951
+ const resuming = new Map();
952
+ /**
953
+ * Rebuilds a session this instance does not hold from its shared record:
954
+ * `undefined` when the registry has no live record for it, or the SDK's
955
+ * own rejection when the replayed initialize did not bring the transport
956
+ * up (a failed Host or Origin check, which the real request would fail
957
+ * identically).
958
+ */
959
+ const resumeSession = (id, sharedRegistry, req) => {
960
+ const inflight = resuming.get(id);
961
+ if (inflight)
962
+ return inflight;
963
+ pendingSessions += 1;
964
+ const attempt = (async () => {
965
+ const record = await sharedRegistry.get(id);
966
+ if (record === undefined ||
967
+ (record.expiresAt !== undefined && record.expiresAt <= Date.now())) {
968
+ return undefined;
969
+ }
970
+ // The pending slot is already counted, so this instance can be full
971
+ // by exactly one.
972
+ if (sessions.size + pendingSessions > maxSessions) {
973
+ return { status: 503, body: sessionLimitBody };
974
+ }
975
+ const proxyServer = createProxyServer(backends, options);
976
+ const transport = createTransport(id, (sid) => {
977
+ admitSession(sid, transport, proxyServer, record.identity, record.expiresAt);
978
+ });
979
+ try {
980
+ await proxyServer.connect(transport);
981
+ const answer = await replayInitialize(transport, record.initialize, {
982
+ ...(req.headers.host === undefined ? {} : { host: req.headers.host }),
983
+ ...(req.headers.origin === undefined
984
+ ? {}
985
+ : { origin: req.headers.origin }),
986
+ });
987
+ return sessions.get(id) ?? answer;
988
+ }
989
+ finally {
990
+ // Not admitted, whether the SDK refused the replay or threw: close
991
+ // the orphaned proxy server before it leaks (memory + listChanged
992
+ // fan-out), as the fresh-session path does.
993
+ if (!sessions.has(id))
994
+ void proxyServer.close().catch(reportError);
995
+ }
996
+ })().finally(() => {
997
+ resuming.delete(id);
998
+ pendingSessions -= 1;
999
+ });
1000
+ resuming.set(id, attempt);
1001
+ return attempt;
1002
+ };
298
1003
  const requestHandler = (req, res) => {
299
1004
  const handle = async () => {
300
1005
  if (httpOptions.onRequest !== undefined) {
@@ -302,7 +1007,12 @@ export function startHttpProxy(backend, options = {}, httpOptions = {}) {
302
1007
  try {
303
1008
  allowed = await httpOptions.onRequest(req, res);
304
1009
  }
305
- catch {
1010
+ catch (err) {
1011
+ // Failing closed is the right answer for the caller; it is not an
1012
+ // answer for the operator, whose only signal would otherwise be a
1013
+ // stream of 401s. Same containment the session teardown path
1014
+ // already uses for a throwing `onSessionClosed`.
1015
+ reportError(err);
306
1016
  if (!res.headersSent)
307
1017
  res.writeHead(401).end();
308
1018
  return;
@@ -318,85 +1028,173 @@ export function startHttpProxy(backend, options = {}, httpOptions = {}) {
318
1028
  return;
319
1029
  }
320
1030
  if (method === 'POST') {
321
- applyBodySizeLimit(req, res, httpOptions.maxBodyBytes ?? 4 * 1024 * 1024);
1031
+ const rejected = applyBodySizeLimit(req, res, maxBodyBytes);
1032
+ if (rejected)
1033
+ return;
322
1034
  }
323
1035
  const sessionId = req.headers['mcp-session-id'];
324
1036
  const headers = normalizeHeaders(req.headers);
325
- await httpProxyContext.run(
326
- // Base context — requestId added per-request by getMiddlewareContext()
327
- { transport: 'http' }, async () => {
328
- if (typeof sessionId === 'string') {
329
- // Route to existing session — stamp its resolved identity into context
330
- const session = sessions.get(sessionId);
331
- if (!session) {
332
- res.writeHead(404).end();
1037
+ if (typeof sessionId === 'string') {
1038
+ // Route to an existing session, resuming it from the shared registry
1039
+ // if another process created it — stamp its resolved identity into
1040
+ // context.
1041
+ const session = sessions.get(sessionId) ??
1042
+ (registry
1043
+ ? await resumeSession(sessionId, registry, req)
1044
+ : undefined);
1045
+ if (!session) {
1046
+ res.writeHead(404).end();
1047
+ return;
1048
+ }
1049
+ if ('status' in session) {
1050
+ res
1051
+ .writeHead(session.status, { 'content-type': 'application/json' })
1052
+ .end(session.body);
1053
+ return;
1054
+ }
1055
+ if (httpOptions.validateSession !== undefined) {
1056
+ let valid;
1057
+ try {
1058
+ valid = await httpOptions.validateSession(req, {
1059
+ sessionId,
1060
+ ...(session.identity === undefined
1061
+ ? {}
1062
+ : { identity: session.identity }),
1063
+ });
1064
+ }
1065
+ catch (err) {
1066
+ reportError(err);
1067
+ valid = false;
1068
+ }
1069
+ if (!valid) {
1070
+ if (!res.headersSent)
1071
+ res.writeHead(401).end();
333
1072
  return;
334
1073
  }
335
- const requestContext = {
336
- transport: 'http',
337
- sessionId,
338
- ...(headers === undefined ? {} : { headers }),
339
- ...(session.identity === undefined
340
- ? {}
341
- : { identity: session.identity }),
1074
+ }
1075
+ const requestContext = {
1076
+ transport: 'http',
1077
+ sessionId,
1078
+ ...(headers === undefined ? {} : { headers }),
1079
+ ...(session.identity === undefined
1080
+ ? {}
1081
+ : { identity: session.identity }),
1082
+ };
1083
+ await httpProxyContext.run(requestContext, () => session.transport.handleRequest(req, res));
1084
+ }
1085
+ else {
1086
+ // New session — only an initialize POST may create one. A
1087
+ // session-less GET/DELETE can never initialize, so reject it
1088
+ // before constructing a Server/transport (which would otherwise
1089
+ // leak into the listChanged fan-out bus).
1090
+ if (method !== 'POST') {
1091
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({
1092
+ jsonrpc: '2.0',
1093
+ error: {
1094
+ code: -32000,
1095
+ message: 'Bad Request: no valid session ID provided',
1096
+ },
1097
+ id: null,
1098
+ }));
1099
+ return;
1100
+ }
1101
+ // Count this initialize as pending so concurrent initializes
1102
+ // cannot overshoot maxSessions while identity resolution awaits.
1103
+ if (sessions.size + pendingSessions >= maxSessions) {
1104
+ res
1105
+ .writeHead(503, { 'content-type': 'application/json' })
1106
+ .end(sessionLimitBody);
1107
+ return;
1108
+ }
1109
+ pendingSessions += 1;
1110
+ // The shared record carries the client's initialize params, and the
1111
+ // SDK owns parsing the body, so tap the bytes as they arrive rather
1112
+ // than reading the stream out from under it. The same seam the body
1113
+ // limit uses.
1114
+ const bodyChunks = [];
1115
+ if (registry) {
1116
+ const push = req.push.bind(req);
1117
+ req.push = (chunk, enc) => {
1118
+ if (chunk !== null) {
1119
+ bodyChunks.push(typeof chunk === 'string' ? Buffer.from(chunk, enc) : chunk);
1120
+ }
1121
+ return push(chunk, enc);
342
1122
  };
343
- await httpProxyContext.run(requestContext, () => session.transport.handleRequest(req, res));
344
1123
  }
345
- else {
346
- // New session (initialize request)
347
- if (httpOptions.maxSessions !== undefined &&
348
- sessions.size >= httpOptions.maxSessions) {
349
- res.writeHead(503).end();
350
- return;
351
- }
1124
+ try {
352
1125
  // Resolve identity once for the lifetime of this session
353
1126
  let identity;
354
1127
  if (httpOptions.resolveIdentity !== undefined) {
355
1128
  try {
356
1129
  identity = await httpOptions.resolveIdentity(req);
357
1130
  }
358
- catch {
1131
+ catch (err) {
1132
+ reportError(err);
359
1133
  if (!res.headersSent)
360
1134
  res.writeHead(401).end();
361
1135
  return;
362
1136
  }
363
1137
  }
364
- const proxyServer = createProxyServer(backend, options);
365
- const transport = new StreamableHTTPServerTransport({
366
- sessionIdGenerator: randomUUID,
367
- ...(eventStore ? { eventStore } : {}),
368
- onsessioninitialized: (id) => {
369
- sessions.set(id, { transport, proxyServer, identity });
370
- if (httpOptions.sessionTtlMs !== undefined) {
371
- const timer = setTimeout(() => {
372
- const session = sessions.get(id);
373
- if (session) {
374
- sessions.delete(id);
375
- void session.proxyServer.close();
376
- }
377
- }, httpOptions.sessionTtlMs);
378
- timer.unref();
1138
+ const proxyServer = createProxyServer(backends, options);
1139
+ const sessionId = randomUUID();
1140
+ const transport = createTransport(sessionId, async (id) => {
1141
+ // The deadline is fixed here and travels with the record, so a
1142
+ // resume elsewhere inherits what is left of it. Infinity opts
1143
+ // out; setTimeout would clamp it to 1ms.
1144
+ const expiresAt = Number.isFinite(sessionTtlMs)
1145
+ ? Date.now() + sessionTtlMs
1146
+ : undefined;
1147
+ // The SDK awaits this hook before it answers, so the record is
1148
+ // durable before the client can present the id anywhere. A
1149
+ // throw here becomes the SDK's 400 with the error text in the
1150
+ // body, so the real cause goes to onError and the client gets a
1151
+ // message that names nothing about the backing store.
1152
+ if (registry) {
1153
+ try {
1154
+ await registry.set(id, {
1155
+ initialize: initializeParamsOf(Buffer.concat(bodyChunks).toString()),
1156
+ ...(identity === undefined ? {} : { identity }),
1157
+ ...(expiresAt === undefined ? {} : { expiresAt }),
1158
+ });
379
1159
  }
380
- },
381
- onsessionclosed: (id) => {
382
- sessions.delete(id);
383
- httpOptions.onSessionClosed?.(id);
384
- },
1160
+ catch (err) {
1161
+ reportError(err);
1162
+ throw new Error('mcpose: session registry unavailable');
1163
+ }
1164
+ }
1165
+ admitSession(id, transport, proxyServer, identity, expiresAt);
385
1166
  });
386
1167
  const requestContext = {
387
1168
  transport: 'http',
388
1169
  ...(headers === undefined ? {} : { headers }),
389
1170
  ...(identity === undefined ? {} : { identity }),
390
1171
  };
1172
+ // See backendClient.ts: the SDK's transports are not assignable to
1173
+ // its own `Transport` interface under `exactOptionalPropertyTypes`
1174
+ // (here `onclose?: () => void` vs `onclose: (() => void) | undefined`).
391
1175
  await proxyServer.connect(transport);
392
- await httpProxyContext.run(requestContext, () => transport.handleRequest(req, res));
1176
+ try {
1177
+ await httpProxyContext.run(requestContext, () => transport.handleRequest(req, res));
1178
+ }
1179
+ finally {
1180
+ // No session came up — a non-initialize body the transport
1181
+ // rejected, or a registry that refused the record — so close the
1182
+ // orphaned proxy server before it leaks (memory + listChanged
1183
+ // fan-out).
1184
+ if (!sessions.has(sessionId)) {
1185
+ void proxyServer.close().catch(reportError);
1186
+ }
1187
+ }
393
1188
  }
394
- });
1189
+ finally {
1190
+ pendingSessions -= 1;
1191
+ }
1192
+ }
395
1193
  };
396
1194
  handle().catch((err) => {
397
1195
  if (!res.headersSent)
398
1196
  res.writeHead(500).end();
399
- (httpOptions.onError ?? console.error)(err);
1197
+ reportError(err);
400
1198
  });
401
1199
  };
402
1200
  const server = httpOptions.tlsOptions
@@ -404,21 +1202,60 @@ export function startHttpProxy(backend, options = {}, httpOptions = {}) {
404
1202
  : http.createServer(requestHandler);
405
1203
  const rawClose = server.close.bind(server);
406
1204
  let shuttingDown = false;
407
- server.close = ((callback) => {
1205
+ server.close = (callback) => {
408
1206
  if (shuttingDown)
409
1207
  return rawClose(callback);
410
1208
  shuttingDown = true;
411
- const activeSessions = [...sessions.values()];
412
- sessions.clear();
413
- void Promise.allSettled(activeSessions.map(({ proxyServer }) => proxyServer.close())).finally(() => {
1209
+ // Tear down every session through the single teardown path (clears TTL
1210
+ // timers, fires and awaits onSessionClosed so audit manifests flush
1211
+ // before the close callback runs).
1212
+ // Shared records are kept: the sessions are meant to resume on the next
1213
+ // process, and their deadlines expire them on their own.
1214
+ void Promise.allSettled([...sessions.keys()].map((id) => destroySession(id, true))).finally(() => {
414
1215
  rawClose(callback);
1216
+ // Idle keep-alive and lingering SSE sockets would otherwise keep the
1217
+ // close callback pending indefinitely.
1218
+ server.closeAllConnections();
415
1219
  });
416
1220
  return server;
417
- });
1221
+ };
1222
+ if (!loopback && httpOptions.resolveIdentity === undefined) {
1223
+ reportError(new Error(`mcpose: startHttpProxy is binding non-loopback address "${host}" without resolveIdentity — ` +
1224
+ 'anything that can route to this host can call the upstream with ' +
1225
+ "the proxy's credentials. Pass resolveIdentity, or bind 127.0.0.1."));
1226
+ }
1227
+ // The SDK transport validates Host/Origin only against a non-empty list,
1228
+ // and no list is derived for a non-loopback bind, so this combination is
1229
+ // an inert flag — the false-confidence case the derived defaults exist
1230
+ // to remove.
1231
+ if (dnsRebindingProtection &&
1232
+ !loopback &&
1233
+ httpOptions.allowedHosts === undefined &&
1234
+ httpOptions.allowedOrigins === undefined) {
1235
+ reportError(new Error('mcpose: enableDnsRebindingProtection is on, but no allowedHosts or ' +
1236
+ 'allowedOrigins are set and none are derived for a non-loopback ' +
1237
+ 'bind, so the transport validates nothing. Pass allowedHosts ' +
1238
+ 'and/or allowedOrigins.'));
1239
+ }
418
1240
  return new Promise((resolve, reject) => {
419
1241
  server.once('error', reject);
420
- server.listen(port, ...(host ? [host] : []), () => {
1242
+ server.listen(port, host, () => {
421
1243
  server.off('error', reject);
1244
+ // Route post-listen server errors (e.g. EMFILE) to onError instead of
1245
+ // crashing the process with an unhandled 'error' event.
1246
+ server.on('error', reportError);
1247
+ if (dnsRebindingProtection && loopback) {
1248
+ const addr = server.address();
1249
+ const actualPort = addr !== null && typeof addr === 'object' ? addr.port : port;
1250
+ const hosts = [
1251
+ `127.0.0.1:${actualPort}`,
1252
+ `localhost:${actualPort}`,
1253
+ `[::1]:${actualPort}`,
1254
+ ];
1255
+ const scheme = httpOptions.tlsOptions ? 'https' : 'http';
1256
+ derivedAllowedHosts = hosts;
1257
+ derivedAllowedOrigins = hosts.map((h) => `${scheme}://${h}`);
1258
+ }
422
1259
  resolve(server);
423
1260
  });
424
1261
  });