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