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/src/core.ts ADDED
@@ -0,0 +1,2129 @@
1
+ /** MCP proxy core: wires server→upstream through middleware pipelines. */
2
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
+ import * as http from 'node:http';
4
+ import * as https from 'node:https';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
9
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
10
+ import {
11
+ CallToolRequestSchema,
12
+ ErrorCode,
13
+ GetPromptRequestSchema,
14
+ ListPromptsRequestSchema,
15
+ ListResourcesRequestSchema,
16
+ type ListToolsRequest,
17
+ type ListToolsResult,
18
+ ListToolsRequestSchema,
19
+ PromptListChangedNotificationSchema,
20
+ ReadResourceRequestSchema,
21
+ ResourceListChangedNotificationSchema,
22
+ ToolListChangedNotificationSchema,
23
+ type CallToolRequest,
24
+ type CallToolRequestParams,
25
+ type CallToolResult,
26
+ type CompatibilityCallToolResult,
27
+ type ContentBlock,
28
+ type GetPromptRequest,
29
+ type GetPromptResult,
30
+ type TextContent,
31
+ type ReadResourceRequest,
32
+ type ReadResourceResult,
33
+ type ServerCapabilities,
34
+ type Tool,
35
+ McpError,
36
+ } from '@modelcontextprotocol/sdk/types.js';
37
+ import { pipe, isPassThroughObserver, type Middleware } from './middleware.js';
38
+ import type { HiddenToolPredicate } from './hiddenTools.js';
39
+ import type { BackendClient } from './backendClient.js';
40
+ import {
41
+ createProxyContext,
42
+ type ProxyContext,
43
+ type ProxyIdentity,
44
+ } from './proxyContext.js';
45
+ import type { Identity } from './identity.js';
46
+ import type {
47
+ BackendDegradedTelemetryEvent,
48
+ TelemetryEvent,
49
+ ToolCallTelemetryEvent,
50
+ } from './telemetry.js';
51
+ import {
52
+ listAcrossMesh,
53
+ namespaceName,
54
+ namespaceUri,
55
+ normalizeBackends,
56
+ routeNamespaced,
57
+ routeResourceUri,
58
+ type Backends,
59
+ type MeshEntry,
60
+ } from './mesh.js';
61
+ import {
62
+ attachDelegationMeta,
63
+ detectDelegationLoop,
64
+ readInboundDelegation,
65
+ } from './delegation.js';
66
+ import { createInMemoryEventStore, scopeEventStore } from './eventStore.js';
67
+ import {
68
+ initializeParamsOf,
69
+ replayInitialize,
70
+ type SessionRegistry,
71
+ } from './sessionRegistry.js';
72
+ import type { EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
73
+ import { isRejectionReason, rejectionMcpError } from './rejection.js';
74
+ import type { RejectionReason } from './rejection.js';
75
+ import { VERSION } from './version.js';
76
+
77
+ /**
78
+ * Middleware for tool calls.
79
+ * Uses `CompatibilityCallToolResult` to cover legacy `{ toolResult }` shape
80
+ * (protocol 2024-10-07). Narrow with `hasToolContent()` before accessing `.content`.
81
+ */
82
+ export type ToolMiddleware = Middleware<
83
+ CallToolRequest,
84
+ CompatibilityCallToolResult
85
+ >;
86
+
87
+ /** Middleware for resource reads. */
88
+ export type ResourceMiddleware = Middleware<
89
+ ReadResourceRequest,
90
+ ReadResourceResult
91
+ >;
92
+
93
+ /** Middleware for prompt fetches (`prompts/get`). */
94
+ export type PromptMiddleware = Middleware<GetPromptRequest, GetPromptResult>;
95
+
96
+ /** Middleware for tool-list responses. */
97
+ export type ListToolsMiddleware = Middleware<ListToolsRequest, ListToolsResult>;
98
+
99
+ /**
100
+ * The result channels of a `CallToolResult` — everything a redaction or
101
+ * transform middleware must account for:
102
+ *
103
+ * 1. `content` blocks (text, image, audio, resource link, embedded
104
+ * resource), each carrying its own block-level `_meta`,
105
+ * 2. `structuredContent`, the machine-readable mirror of the content,
106
+ * 3. result-level `_meta`,
107
+ * 4. `isError`,
108
+ * 5. unknown extra keys via the index signature.
109
+ *
110
+ * Redaction that maps only `.content` misses 2, 3, and the non-text
111
+ * members of 1. Use {@link mapToolResult} to cover every payload channel
112
+ * by construction; channel 3 is stripped at the proxy boundary by default
113
+ * via `ProxyOptions.stripResultMeta` (ADR-0009).
114
+ *
115
+ * This guard narrows `CompatibilityCallToolResult` to `CallToolResult`
116
+ * (has `.content` array). Both union members carry `[x: string]: unknown`,
117
+ * so this avoids unsafe casts.
118
+ *
119
+ * A guard, not an assertion: the union's two members are both objects, but a
120
+ * pipeline result is not the union. The legacy shape has no `.content`, and a
121
+ * handler that produced nothing at all (a local tool with a missing `return`,
122
+ * or a middleware that dropped the result) produces a nullish one, which has
123
+ * none either. Both answer `false` here. Throwing instead replaced whatever
124
+ * the pipeline produced with `TypeError: Cannot read properties of undefined
125
+ * (reading 'content')` — inside `mapToolResult`, and inside the `tools/call`
126
+ * handler's telemetry read of `result.isError`.
127
+ */
128
+ export function hasToolContent(
129
+ r: CompatibilityCallToolResult,
130
+ ): r is CallToolResult {
131
+ return (
132
+ r !== null &&
133
+ typeof r === 'object' &&
134
+ Array.isArray((r as { content?: unknown }).content)
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Handlers for {@link mapToolResult}, one per payload channel. All three
140
+ * are required, so forgetting a channel is a compile error rather than a
141
+ * silent leak.
142
+ */
143
+ export interface ToolResultHandlers {
144
+ /** Maps a text content block. Return `null` to drop the block. */
145
+ onText: (block: TextContent) => ContentBlock | null;
146
+ /**
147
+ * Maps a non-text content block (image, audio, resource link, embedded
148
+ * resource). Return `null` to drop the block.
149
+ */
150
+ onOther: (block: Exclude<ContentBlock, TextContent>) => ContentBlock | null;
151
+ /**
152
+ * Maps `structuredContent`. Called only when the field is present.
153
+ * Return `undefined` to remove the field entirely.
154
+ */
155
+ onStructured: (
156
+ structured: Record<string, unknown>,
157
+ ) => Record<string, unknown> | undefined;
158
+ }
159
+
160
+ /**
161
+ * Maps every payload channel of a tool result through the given handlers.
162
+ *
163
+ * The legacy `{ toolResult }` shape (protocol 2024-10-07) is returned
164
+ * untouched. `isError`, result-level `_meta`, and unknown extra keys are
165
+ * preserved by spread — result-level `_meta` needs no handler because the
166
+ * proxy strips it by default (`stripResultMeta`, ADR-0009); with
167
+ * `stripResultMeta: false` forwarding it is the consumer's explicit choice.
168
+ */
169
+ export function mapToolResult(
170
+ result: CompatibilityCallToolResult,
171
+ handlers: ToolResultHandlers,
172
+ ): CompatibilityCallToolResult {
173
+ if (!hasToolContent(result)) return result;
174
+ const content = result.content
175
+ .map((block) =>
176
+ block.type === 'text' ? handlers.onText(block) : handlers.onOther(block),
177
+ )
178
+ .filter((block): block is ContentBlock => block !== null);
179
+ const structured =
180
+ result.structuredContent === undefined
181
+ ? undefined
182
+ : handlers.onStructured(result.structuredContent);
183
+ const { structuredContent: _dropped, ...rest } = result;
184
+ return {
185
+ ...rest,
186
+ content,
187
+ ...(structured === undefined ? {} : { structuredContent: structured }),
188
+ };
189
+ }
190
+
191
+ /** HTTP transport options for {@link startHttpProxy}. */
192
+ export interface HttpProxyOptions {
193
+ /** Default: 3000 */
194
+ port?: number;
195
+ /**
196
+ * Bind address. Default: `'127.0.0.1'` (loopback).
197
+ *
198
+ * Binding a non-loopback address (e.g. `'0.0.0.0'`) exposes an
199
+ * unauthenticated MCP proxy holding an authenticated upstream session to
200
+ * the network, so it is a deliberate opt-in; doing so without
201
+ * {@link resolveIdentity} reports a startup warning through {@link onError}.
202
+ */
203
+ host?: string;
204
+ /** Default: '/mcp' */
205
+ path?: string;
206
+ /**
207
+ * Called for every incoming request before MCP handling. Return false to
208
+ * block (caller writes its own response). Throw to get a 401, with the error
209
+ * reported through {@link onError}.
210
+ */
211
+ onRequest?: (
212
+ req: http.IncomingMessage,
213
+ res: http.ServerResponse,
214
+ ) => boolean | Promise<boolean>;
215
+ /**
216
+ * Called on unhandled errors instead of console.error, and on every auth
217
+ * hook that throws: the request still fails closed, and the operator still
218
+ * sees why. If this hook itself throws or returns a rejected promise, the
219
+ * failure is contained and changes nothing the caller sees.
220
+ */
221
+ onError?: (err: unknown) => unknown;
222
+ /**
223
+ * Maximum request body size in bytes. Excess POST bodies return 413.
224
+ * Default: 4 MB. Set to `Infinity` to opt out of the cap.
225
+ *
226
+ * `0` is a valid value and means "reject every POST body", so it is not the
227
+ * opt-out. The two shapes that are mistakes rather than choices throw at
228
+ * startup: a negative number returns 413 for every POST, and `NaN`, which is
229
+ * what `Number(process.env.MAX_BODY_BYTES)` returns for an unset key,
230
+ * compares false against every body size and so gates nothing at all.
231
+ */
232
+ maxBodyBytes?: number;
233
+ /**
234
+ * Maximum number of concurrent MCP sessions. Excess requests return 503.
235
+ * Default: 1000. Set to `Infinity` to opt out of the cap.
236
+ *
237
+ * `0` is a valid value and means "reject every session", so it is not the
238
+ * opt-out.
239
+ */
240
+ maxSessions?: number;
241
+ /**
242
+ * Session TTL in milliseconds. Sessions are closed after this duration,
243
+ * which fires {@link onSessionClosed}. Default: 30 minutes. Finite values
244
+ * must not exceed Node's maximum timer delay of 2,147,483,647 ms. Set to
245
+ * `Infinity` to opt out of the TTL.
246
+ */
247
+ sessionTtlMs?: number;
248
+ /**
249
+ * Resolves caller identity from the initial session request.
250
+ * Called once per new session; the result is stamped on every
251
+ * {@link ProxyContext} within that session.
252
+ *
253
+ * Supply a JWT extractor, mTLS cert reader, API-key lookup, or any async
254
+ * function returning an {@link Identity}. Errors thrown here abort the
255
+ * session with a 401 and are reported through {@link onError}.
256
+ *
257
+ * @example
258
+ * resolveIdentity: extractJwtIdentity({ jwksUri: '...' })
259
+ */
260
+ resolveIdentity?: (req: http.IncomingMessage) => Identity | Promise<Identity>;
261
+ /**
262
+ * TLS options for mutual TLS (mTLS). When provided, the proxy listens on
263
+ * HTTPS and requires client certificates signed by the supplied CA.
264
+ *
265
+ * @example
266
+ * tlsOptions: {
267
+ * key: fs.readFileSync('server.key'),
268
+ * cert: fs.readFileSync('server.crt'),
269
+ * ca: fs.readFileSync('trusted-ca.crt'),
270
+ * requestCert: true,
271
+ * rejectUnauthorized: true,
272
+ * }
273
+ */
274
+ tlsOptions?: https.ServerOptions;
275
+ /**
276
+ * Event store for SSE reconnect replay. Defaults to an in-memory store
277
+ * (suitable for single-instance deployments). For multi-instance / HA
278
+ * deployments, supply a Redis or Postgres-backed implementation.
279
+ *
280
+ * Stream ids reach the store namespaced as `<sessionId>:<streamId>`, so
281
+ * one store serves every session without their replay histories mixing.
282
+ *
283
+ * Set to `null` to disable reconnect replay entirely.
284
+ */
285
+ eventStore?: EventStore | null;
286
+ /**
287
+ * Shared record of live sessions, so a client can resume after a proxy
288
+ * restart or on another instance behind a load balancer. Without one,
289
+ * sessions live only in this process and a resume against a fresh process
290
+ * is a 404.
291
+ *
292
+ * A record is written before the initialize response goes out and read
293
+ * only for an `mcp-session-id` this instance does not hold; the resumed
294
+ * session negotiates what the original did, keeps its identity, and dies
295
+ * at the original deadline. Client DELETE and TTL expiry delete the
296
+ * record, server shutdown keeps it. Pair it with the matching
297
+ * {@link eventStore} adapter, or a resumed session has no replay history.
298
+ *
299
+ * @example
300
+ * sessionRegistry: createRedisSessionRegistry(redis),
301
+ * eventStore: createRedisEventStore(redis),
302
+ */
303
+ sessionRegistry?: SessionRegistry;
304
+ /**
305
+ * Called when a session is closed (client DELETE, TTL expiry, or server
306
+ * shutdown). Wire {@link AuditMiddlewareHandle.closeSession} here to flush
307
+ * the ReplayManifest for the session.
308
+ *
309
+ * A returned promise is awaited: `server.close()` does not complete until
310
+ * every session's hook has settled, so a host that exits in the close
311
+ * callback never loses a final manifest. A throw or a rejection is routed
312
+ * to {@link onError} and never breaks teardown.
313
+ *
314
+ * @example
315
+ * onSessionClosed: (sessionId) => auditHandle.closeSession(sessionId)
316
+ */
317
+ onSessionClosed?: (sessionId: string) => unknown;
318
+ /**
319
+ * Re-validates an existing session on every routed request. Return `false`
320
+ * (or throw, which is reported through {@link onError}) to reject with 401.
321
+ * Use to bind sessions to their original credential — e.g. re-check the
322
+ * bearer token — so a leaked `mcp-session-id` alone cannot take over a
323
+ * session.
324
+ */
325
+ validateSession?: (
326
+ req: http.IncomingMessage,
327
+ session: { sessionId: string; identity?: Identity },
328
+ ) => boolean | Promise<boolean>;
329
+ /**
330
+ * Hosts allowed in the `Host` header when
331
+ * {@link enableDnsRebindingProtection} is on. Forwarded to the SDK
332
+ * transport, which only validates against a non-empty list.
333
+ *
334
+ * Default: for a loopback bind, derived from the effective bind address
335
+ * and the real listening port (`127.0.0.1:<port>`, `localhost:<port>`,
336
+ * `[::1]:<port>`). An explicit value is used verbatim, never merged with
337
+ * the derived list. No list is derived for a non-loopback bind.
338
+ */
339
+ allowedHosts?: string[];
340
+ /**
341
+ * Origins allowed in the `Origin` header. Forwarded to the SDK transport,
342
+ * which only validates against a non-empty list.
343
+ *
344
+ * Default: for a loopback bind, the derived {@link allowedHosts} entries
345
+ * as origins (`http://` or `https://` matching {@link tlsOptions}). An
346
+ * explicit value is used verbatim, never merged.
347
+ */
348
+ allowedOrigins?: string[];
349
+ /**
350
+ * Enables the SDK transport's DNS-rebinding protection (Host/Origin
351
+ * checks). Default: `true` when the effective bind address is loopback,
352
+ * `false` otherwise (a non-loopback bind usually sits behind a gateway
353
+ * that rewrites `Host`).
354
+ *
355
+ * Enabling this on a non-loopback bind without explicit
356
+ * {@link allowedHosts} / {@link allowedOrigins} validates nothing and
357
+ * reports a startup warning through {@link onError}.
358
+ */
359
+ enableDnsRebindingProtection?: boolean;
360
+ }
361
+
362
+ /**
363
+ * A tool the proxy implements itself, instead of forwarding to the
364
+ * upstream. See ADR-0007.
365
+ */
366
+ export interface LocalTool {
367
+ /** The tool definition advertised in `tools/list`. */
368
+ tool: Tool;
369
+ /**
370
+ * Runs instead of the upstream call, from inside the innermost `next`,
371
+ * so the whole `toolMiddleware` pipeline (audit, redaction) still
372
+ * applies.
373
+ */
374
+ handler: (
375
+ params: CallToolRequestParams,
376
+ context: ProxyContext,
377
+ ) => Promise<CallToolResult>;
378
+ }
379
+
380
+ /** Proxy server options. */
381
+ export interface ProxyOptions {
382
+ /**
383
+ * Human-readable name for this proxy instance. Required, and rejected at
384
+ * construction when empty or whitespace-only.
385
+ *
386
+ * It is stamped onto every `ProxyContext`, every audit event, and the
387
+ * `ReplayManifest` as provenance (ADR-0012), so a default would record a
388
+ * whole fleet under one name: attribution in appearance only (#122).
389
+ */
390
+ name: string;
391
+
392
+ /**
393
+ * Version of this proxy server (yours, not the mcpose library). MCP clients see
394
+ * it in the `initialize` response. Defaults to the mcpose library version when
395
+ * omitted; set it to your own release version when you ship your proxy.
396
+ */
397
+ version?: string;
398
+
399
+ /**
400
+ * Tool middleware in response-processing order (first = innermost).
401
+ * @example [piiMW, auditMW] // pii redacts first, audit logs clean data
402
+ */
403
+ toolMiddleware?: ReadonlyArray<ToolMiddleware>;
404
+
405
+ /** Resource middleware in response-processing order (first = innermost). */
406
+ resourceMiddleware?: ReadonlyArray<ResourceMiddleware>;
407
+
408
+ /**
409
+ * Prompt middleware in response-processing order (first = innermost),
410
+ * per ADR-0002. Runs around every `prompts/get`, in both 1:1 and mesh
411
+ * mode.
412
+ *
413
+ * Mesh routing and the `BACKEND_UNROUTABLE` rejection for an unroutable
414
+ * prompt name happen inside the innermost `next` (the ADR-0007 pattern),
415
+ * so an observing middleware such as audit records every prompt call
416
+ * including the rejected ones.
417
+ *
418
+ * `prompts/list` has no pipeline yet; hiding and pass-through for prompts
419
+ * are deferred with it (ADR-0014).
420
+ */
421
+ promptMiddleware?: ReadonlyArray<PromptMiddleware>;
422
+
423
+ /** Tool-list middleware in response-processing order (first = innermost). */
424
+ listToolsMiddleware?: ReadonlyArray<ListToolsMiddleware>;
425
+
426
+ /**
427
+ * Tools that skip transforming middleware — upstream response forwarded
428
+ * as-is. Middleware wrapped in `markPassThroughObserver()` (e.g. audit)
429
+ * still runs for these tools.
430
+ */
431
+ passThroughTools?: ReadonlyArray<string>;
432
+
433
+ /**
434
+ * Resources that skip middleware — upstream response forwarded as-is.
435
+ * Matched against the URI the client sees, which in mesh mode is
436
+ * `mcpose://<backendKey>/<uri>` (ADR-0022).
437
+ */
438
+ passThroughResources?: ReadonlyArray<string>;
439
+
440
+ /**
441
+ * Tools hidden from list_tools and rejected at runtime with
442
+ * MethodNotFound. Precedence: a tool listed here AND in
443
+ * `passThroughTools` stays hidden. Hidden filtering is applied both
444
+ * before and after `listToolsMiddleware`, so list middleware cannot
445
+ * re-expose a hidden tool.
446
+ *
447
+ * A name array cannot see through a dispatcher (meta-tool) that takes
448
+ * the real tool name as an argument. Pass a {@link HiddenToolPredicate}
449
+ * (e.g. {@link dispatcherAwareBlock}) to close that bypass: it receives
450
+ * `undefined` args during list filtering and always an object at call
451
+ * time. See ADR-0006.
452
+ */
453
+ hiddenTools?: ReadonlyArray<string> | HiddenToolPredicate;
454
+
455
+ /**
456
+ * Resources hidden from list_resources and rejected at runtime with
457
+ * InvalidRequest. Matched against the URI the client sees, which in mesh
458
+ * mode is `mcpose://<backendKey>/<uri>` (ADR-0022).
459
+ */
460
+ hiddenResources?: ReadonlyArray<string>;
461
+
462
+ /**
463
+ * Strips `params._meta` from every request the proxy forwards (tool
464
+ * calls, resource reads, list and prompt calls). Default: `true`.
465
+ *
466
+ * MCP clients put correlation identifiers there (VS Code sends
467
+ * `progressToken`, a W3C `traceparent`, and `vscode/conversationId`),
468
+ * and the upstream is frequently a third party. The strip happens at the
469
+ * proxy boundary, before the pipeline, so middleware sees the stripped
470
+ * request and can still add its own `_meta` deliberately. Progress relay
471
+ * is unaffected: the proxy reads the client's progress token from the
472
+ * server-side request `extra`, and the SDK client stamps its own token
473
+ * on the upstream request.
474
+ *
475
+ * Applies to `passThroughTools` too; disable only globally with `false`
476
+ * for upstreams that read other `_meta` keys. See ADR-0008.
477
+ */
478
+ stripRequestMeta?: boolean;
479
+
480
+ /**
481
+ * Strips top-level `_meta` from every result the proxy returns from the
482
+ * upstream (tool calls, resource reads, list and prompt calls).
483
+ * Default: `true`.
484
+ *
485
+ * The response-direction mirror of {@link stripRequestMeta}: upstreams
486
+ * stamp correlation identifiers there (the SDK stamps
487
+ * `io.modelcontextprotocol/related-task`), and the client is a third
488
+ * party to them. The strip happens at the upstream boundary, inside the
489
+ * innermost `next`, so middleware sees the stripped result and any
490
+ * `_meta` middleware adds deliberately still reaches the client.
491
+ *
492
+ * Local tool results are not stripped (they have no upstream), and
493
+ * nested `_meta` (per-tool in `tools[]`, per-block in `content`) is
494
+ * untouched. Applies to pass-through tools and resources too; disable
495
+ * only globally with `false`. See ADR-0009.
496
+ */
497
+ stripResultMeta?: boolean;
498
+
499
+ /**
500
+ * Tools the proxy implements itself. Local tools appear in `tools/list`
501
+ * (first page only, so pagination does not duplicate them) and route to
502
+ * their handler instead of the upstream, from inside the innermost
503
+ * `next`, so the full `toolMiddleware` pipeline still runs.
504
+ *
505
+ * Precedence: `hiddenTools` beats a local tool; a local tool beats (and
506
+ * shadows) an upstream tool of the same name; `passThroughTools` has no
507
+ * effect on a local tool, because pass-through means "forward the
508
+ * upstream response as-is" and a local tool has no upstream.
509
+ *
510
+ * The proxy advertises the `tools` capability when this is non-empty
511
+ * even if the upstream has none. A duplicate local tool name throws at
512
+ * {@link createProxyServer}. See ADR-0007.
513
+ */
514
+ localTools?: ReadonlyArray<LocalTool>;
515
+
516
+ /**
517
+ * Called after every tool call with timing and outcome data. Wire to any
518
+ * custom telemetry sink, or to `createOtelTelemetry` from `@mcpose/otel`
519
+ * for OpenTelemetry spans.
520
+ * A sink that throws, or whose returned promise rejects, is logged and never
521
+ * fails the tool call. The sink is not awaited, so a slow one does not delay
522
+ * the call either.
523
+ */
524
+ onTelemetry?: (event: TelemetryEvent) => unknown;
525
+ }
526
+
527
+ type ProgressToken = string | number;
528
+
529
+ /**
530
+ * Hands one event to the telemetry sink. A sink that throws, or whose returned
531
+ * promise rejects, is logged here and nowhere else: the caller is already past
532
+ * the point where the tool call could fail, and a rejection that nobody observes
533
+ * ends the process on Node's default `--unhandled-rejections=throw`.
534
+ *
535
+ * The sink is called, not awaited, so a slow exporter cannot delay a tool call.
536
+ *
537
+ * `Promise.resolve` rather than an `instanceof Promise` test: a sink loaded from
538
+ * another realm (`node:vm`), or one handing back a non-native thenable, would
539
+ * fail the latter and leave its rejection unobserved all the same.
540
+ */
541
+ function callTelemetrySink(
542
+ sink: ((event: TelemetryEvent) => unknown) | undefined,
543
+ event: TelemetryEvent,
544
+ ): void {
545
+ try {
546
+ Promise.resolve(sink?.(event)).catch((err: unknown) => {
547
+ console.error(err);
548
+ });
549
+ } catch (err) {
550
+ console.error(err);
551
+ }
552
+ }
553
+
554
+ type BackendRequestOptions = Parameters<BackendClient['listTools']>[1];
555
+ // Structural mirror of the SDK's `RequestHandlerExtra`. The SDK declares its
556
+ // optional members as `?: T | undefined`, so this type has to as well to stay
557
+ // assignable under `exactOptionalPropertyTypes`.
558
+ type ProxyRequestExtra = {
559
+ signal?: AbortSignal | undefined;
560
+ _meta?: { progressToken?: ProgressToken | undefined } | undefined;
561
+ sendNotification?: (notification: {
562
+ method: 'notifications/progress';
563
+ params: {
564
+ progressToken: ProgressToken;
565
+ progress: number;
566
+ total?: number;
567
+ message?: string;
568
+ };
569
+ }) => Promise<void>;
570
+ };
571
+
572
+ /**
573
+ * Subscribers to one backend's list-changed notifications, with the
574
+ * capabilities each proxy server advertises. The handlers are registered
575
+ * from the backend's own capabilities rather than any one subscriber's, so
576
+ * a backend shared between proxies that advertise different surfaces
577
+ * forwards every surface it has, and the fan-out filters per subscriber.
578
+ */
579
+ type ListChangedBus = {
580
+ servers: Set<Server>;
581
+ };
582
+
583
+ type ListChangedSurface = 'tools' | 'prompts' | 'resources';
584
+
585
+ const listChangedBuses = new WeakMap<BackendClient, ListChangedBus>();
586
+ const httpProxyContext = new AsyncLocalStorage<
587
+ Omit<ProxyContext, 'requestId'>
588
+ >();
589
+
590
+ /**
591
+ * Union of every backend's capabilities, plus the ADR-0007 rule that a
592
+ * non-empty `localTools` advertises `tools` on its own.
593
+ */
594
+ function createProxyCapabilities(
595
+ entries: ReadonlyArray<MeshEntry>,
596
+ hasLocalTools: boolean,
597
+ ): ServerCapabilities {
598
+ const upstreams = entries.map((entry) =>
599
+ entry.client.getServerCapabilities(),
600
+ );
601
+ const listChanged = (supported: boolean) =>
602
+ supported ? { listChanged: true } : {};
603
+
604
+ return {
605
+ ...(upstreams.some((u) => u?.tools) || hasLocalTools
606
+ ? { tools: listChanged(upstreams.some((u) => u?.tools?.listChanged)) }
607
+ : {}),
608
+ ...(upstreams.some((u) => u?.resources)
609
+ ? {
610
+ resources: listChanged(
611
+ upstreams.some((u) => u?.resources?.listChanged),
612
+ ),
613
+ }
614
+ : {}),
615
+ ...(upstreams.some((u) => u?.prompts)
616
+ ? { prompts: listChanged(upstreams.some((u) => u?.prompts?.listChanged)) }
617
+ : {}),
618
+ };
619
+ }
620
+
621
+ function createRequestOptions(
622
+ extra: ProxyRequestExtra = {},
623
+ ): BackendRequestOptions {
624
+ const progressToken = extra._meta?.progressToken;
625
+ // `0` and `''` are legitimate progress tokens — only undefined disables.
626
+ const onprogress =
627
+ progressToken !== undefined && extra.sendNotification
628
+ ? ({
629
+ progress,
630
+ total,
631
+ message,
632
+ }: {
633
+ progress: number;
634
+ total?: number | undefined;
635
+ message?: string | undefined;
636
+ }) => {
637
+ // A client that disconnected mid-call makes sendNotification
638
+ // reject; dropping the progress tick is the correct outcome.
639
+ void extra
640
+ .sendNotification?.({
641
+ method: 'notifications/progress',
642
+ params: {
643
+ progressToken,
644
+ progress,
645
+ ...(total === undefined ? {} : { total }),
646
+ ...(message === undefined ? {} : { message }),
647
+ },
648
+ })
649
+ .catch(() => {});
650
+ }
651
+ : undefined;
652
+
653
+ if (!extra.signal && !onprogress) return undefined;
654
+
655
+ return {
656
+ ...(extra.signal ? { signal: extra.signal } : {}),
657
+ ...(onprogress ? { onprogress } : {}),
658
+ };
659
+ }
660
+
661
+ /**
662
+ * Credential-bearing headers are stripped before headers reach
663
+ * `ProxyContext` (and through it, audit logs). Identity resolution reads
664
+ * the raw `http.IncomingMessage`, so `resolveIdentity` still sees them.
665
+ */
666
+ const SENSITIVE_HEADERS: ReadonlySet<string> = new Set([
667
+ 'authorization',
668
+ 'proxy-authorization',
669
+ 'cookie',
670
+ 'set-cookie',
671
+ 'x-api-key',
672
+ ]);
673
+
674
+ function normalizeHeaders(
675
+ headers: http.IncomingHttpHeaders,
676
+ ): Readonly<Record<string, string>> | undefined {
677
+ const normalized = Object.entries(headers).reduce<Record<string, string>>(
678
+ (acc, [key, value]) => {
679
+ if (SENSITIVE_HEADERS.has(key)) return acc;
680
+ if (typeof value === 'string') {
681
+ acc[key] = value;
682
+ return acc;
683
+ }
684
+ if (Array.isArray(value)) {
685
+ acc[key] = value.join(', ');
686
+ }
687
+ return acc;
688
+ },
689
+ {},
690
+ );
691
+
692
+ return Object.keys(normalized).length ? normalized : undefined;
693
+ }
694
+
695
+ function getMiddlewareContext(
696
+ proxy: ProxyIdentity,
697
+ signal?: AbortSignal,
698
+ delegatedFrom?: Identity[],
699
+ ): ProxyContext {
700
+ const store = httpProxyContext.getStore();
701
+ return createProxyContext({
702
+ ...store,
703
+ ...(signal === undefined ? {} : { signal }),
704
+ // A host-stamped chain wins; an extracted one fills the field only when
705
+ // the host left it unset, which keeps the extraction additive (ADR-0016).
706
+ delegatedFrom: store?.delegatedFrom ?? delegatedFrom,
707
+ proxy,
708
+ });
709
+ }
710
+
711
+ function getRejectionReason(err: unknown): RejectionReason | undefined {
712
+ const reason = (err as { data?: { rejectionReason?: unknown } } | null)?.data
713
+ ?.rejectionReason;
714
+ // A reason outside the union is an ordinary failure, not a rejection (#238).
715
+ return isRejectionReason(reason) ? reason : undefined;
716
+ }
717
+
718
+ /**
719
+ * Removes `params._meta` at the proxy boundary, before the pipeline runs,
720
+ * so middleware sees the stripped request, can still add its own `_meta`
721
+ * deliberately, and audit hashes cover what is actually forwarded.
722
+ * Identity function when there is nothing to strip.
723
+ */
724
+ function stripParamsMeta<
725
+ Req extends { params?: { _meta?: unknown } | undefined },
726
+ >(req: Req): Req {
727
+ if (req.params === undefined || !('_meta' in req.params)) return req;
728
+ const { _meta: _stripped, ...params } = req.params;
729
+ return { ...req, params };
730
+ }
731
+
732
+ /**
733
+ * Removes top-level `_meta` from an upstream result at the proxy boundary,
734
+ * inside the innermost `next`, so middleware sees the stripped result and
735
+ * middleware-added `_meta` still reaches the client. Nested `_meta`
736
+ * (per-tool in `tools[]`, per-block in `content`) is untouched.
737
+ * Identity function when there is nothing to strip.
738
+ */
739
+ function stripTopLevelMeta<Res extends { _meta?: unknown }>(res: Res): Res {
740
+ if (!('_meta' in res)) return res;
741
+ const { _meta: _stripped, ...rest } = res;
742
+ // Omit<Res, '_meta'> still satisfies Res: `_meta` is optional.
743
+ return rest as Res;
744
+ }
745
+
746
+ /**
747
+ * Validates and indexes `localTools`. Throws on a duplicate name: there is
748
+ * no correct way to pick one, and silently keeping the last would route
749
+ * calls to a handler the configuration does not obviously name.
750
+ */
751
+ /**
752
+ * Rejects a blank proxy name at construction, like a duplicate local tool name.
753
+ *
754
+ * The name is recorded as provenance on every `ProxyContext`, audit event, and
755
+ * `ReplayManifest` (ADR-0012), so a blank one produces a trail that looks
756
+ * attributed and is not (#122). The negated check covers all three bad values
757
+ * at once: whitespace-only trims to `''`, and `''` and an omitted name (from a
758
+ * JS caller the type does not bind) are falsy already.
759
+ */
760
+ function assertProxyName(name: string): void {
761
+ if (!name?.trim()) {
762
+ throw new Error(
763
+ `mcpose: name must be a non-empty string, got ${JSON.stringify(name)}`,
764
+ );
765
+ }
766
+ }
767
+
768
+ function buildLocalToolMap(
769
+ localTools: ReadonlyArray<LocalTool> | undefined,
770
+ ): Map<string, LocalTool> {
771
+ const localToolMap = new Map<string, LocalTool>();
772
+ for (const localTool of localTools ?? []) {
773
+ const toolName = localTool.tool.name;
774
+ if (localToolMap.has(toolName)) {
775
+ throw new Error(`mcpose: duplicate local tool name "${toolName}"`);
776
+ }
777
+ localToolMap.set(toolName, localTool);
778
+ }
779
+ return localToolMap;
780
+ }
781
+
782
+ /**
783
+ * The backends advertising one surface, ready to list across and to route
784
+ * by backend key. Only these are queried or routed to, so an otherwise-valid
785
+ * prefix naming a backend without the surface is unroutable rather than a
786
+ * call the upstream cannot serve.
787
+ */
788
+ function surfaceBackends(
789
+ entries: ReadonlyArray<MeshEntry>,
790
+ surface: 'tools' | 'prompts' | 'resources',
791
+ ): {
792
+ entries: ReadonlyArray<MeshEntry>;
793
+ byKey: ReadonlyMap<string, BackendClient>;
794
+ } {
795
+ const selected = entries.filter(
796
+ (entry) => entry.client.getServerCapabilities()?.[surface] !== undefined,
797
+ );
798
+ return {
799
+ entries: selected,
800
+ byKey: new Map(selected.map((entry) => [entry.key, entry.client] as const)),
801
+ };
802
+ }
803
+
804
+ /** Normalizes the `hiddenTools` option to its predicate form. */
805
+ function toHiddenToolPredicate(
806
+ hiddenTools: ReadonlyArray<string> | HiddenToolPredicate | undefined,
807
+ ): HiddenToolPredicate {
808
+ if (typeof hiddenTools === 'function') return hiddenTools;
809
+ const hiddenToolSet = new Set(hiddenTools ?? []);
810
+ return (name) => hiddenToolSet.has(name);
811
+ }
812
+
813
+ function filterHiddenTools(
814
+ result: ListToolsResult,
815
+ isHiddenTool: HiddenToolPredicate,
816
+ ): ListToolsResult {
817
+ // List phase: a listed tool has no arguments, so args is undefined.
818
+ const tools = result.tools.filter(
819
+ (tool) => !isHiddenTool(tool.name, undefined),
820
+ );
821
+ return tools.length === result.tools.length ? result : { ...result, tools };
822
+ }
823
+
824
+ /**
825
+ * Subscribes `server` to every backend's list-changed notifications, so a
826
+ * mesh fans them in through the same per-backend bus a 1:1 proxy uses.
827
+ *
828
+ * Every subscriber advertises the union of its backends' surfaces, so a
829
+ * notification a bus can emit is always one its subscribers accept, and the
830
+ * fan-out needs no per-surface filter.
831
+ */
832
+ function registerListChangedForwarders(
833
+ entries: ReadonlyArray<MeshEntry>,
834
+ server: Server,
835
+ capabilities: ServerCapabilities,
836
+ ): void {
837
+ const leaveBuses: Array<() => void> = [];
838
+
839
+ for (const { client } of entries) {
840
+ const upstream = client.getServerCapabilities() ?? {};
841
+ const surfaces: ReadonlyArray<ListChangedSurface> = [
842
+ 'tools',
843
+ 'prompts',
844
+ 'resources',
845
+ ];
846
+ if (
847
+ !surfaces.some(
848
+ (surface) =>
849
+ capabilities[surface]?.listChanged === true &&
850
+ upstream[surface]?.listChanged === true,
851
+ )
852
+ ) {
853
+ continue;
854
+ }
855
+
856
+ let bus = listChangedBuses.get(client);
857
+
858
+ if (!bus) {
859
+ const servers = new Set<Server>();
860
+ const fanOut = async (
861
+ notify: (proxyServer: Server) => Promise<void>,
862
+ ): Promise<void> => {
863
+ await Promise.allSettled([...servers].map(notify));
864
+ };
865
+
866
+ if (upstream.tools?.listChanged) {
867
+ client.setNotificationHandler(ToolListChangedNotificationSchema, () =>
868
+ fanOut((proxyServer) => proxyServer.sendToolListChanged()),
869
+ );
870
+ }
871
+
872
+ if (upstream.prompts?.listChanged) {
873
+ client.setNotificationHandler(PromptListChangedNotificationSchema, () =>
874
+ fanOut((proxyServer) => proxyServer.sendPromptListChanged()),
875
+ );
876
+ }
877
+
878
+ if (upstream.resources?.listChanged) {
879
+ client.setNotificationHandler(
880
+ ResourceListChangedNotificationSchema,
881
+ () => fanOut((proxyServer) => proxyServer.sendResourceListChanged()),
882
+ );
883
+ }
884
+
885
+ bus = { servers };
886
+ listChangedBuses.set(client, bus);
887
+ }
888
+
889
+ const joined = bus;
890
+ joined.servers.add(server);
891
+
892
+ let active = true;
893
+ leaveBuses.push(() => {
894
+ if (!active) return;
895
+ active = false;
896
+ joined.servers.delete(server);
897
+ if (!joined.servers.size) listChangedBuses.delete(client);
898
+ });
899
+ }
900
+
901
+ if (leaveBuses.length === 0) return;
902
+
903
+ // Accessor instead of plain assignment: a consumer setting `server.onclose`
904
+ // after createProxyServer() must not clobber the bus cleanup (which would
905
+ // leak this server into the fan-out set forever).
906
+ let consumerOnClose: (() => void) | undefined;
907
+ Object.defineProperty(server, 'onclose', {
908
+ configurable: true,
909
+ get: () => () => {
910
+ for (const leaveBus of leaveBuses) leaveBus();
911
+ consumerOnClose?.();
912
+ },
913
+ set: (fn: (() => void) | undefined) => {
914
+ consumerOnClose = fn;
915
+ },
916
+ });
917
+ }
918
+
919
+ /**
920
+ * Creates a proxy MCP server without connecting it to a transport.
921
+ *
922
+ * Mirrors upstream tool/resource/prompt lists and routes requests through
923
+ * middleware pipelines. `prompts/list` is forwarded as-is; `prompts/get`
924
+ * runs `promptMiddleware` (ADR-0014).
925
+ *
926
+ * Uses low-level `Server` (not `McpServer`) — transparent proxying requires
927
+ * generic list interception; `McpServer.tool()` needs names upfront.
928
+ *
929
+ * @param backends - Connected (or mock) upstream MCP client for a 1:1 proxy,
930
+ * or a record of named backends for mesh mode (ADR-0013), where every
931
+ * upstream tool and prompt is exposed as `<backendKey>__<name>`.
932
+ * @param options - Middleware stacks, hidden/passthrough sets.
933
+ * @returns Configured {@link Server} ready to connect.
934
+ */
935
+ export function createProxyServer(
936
+ backends: Backends,
937
+ options: ProxyOptions,
938
+ ): Server {
939
+ assertProxyName(options.name);
940
+ const { mesh, entries } = normalizeBackends(backends);
941
+ for (const { key, client } of entries) {
942
+ if (client.getServerCapabilities() === undefined) {
943
+ throw new Error(
944
+ `mcpose: backend${mesh ? ` "${key}"` : ''} is not connected (getServerCapabilities() returned undefined). Connect the backend before calling createProxyServer().`,
945
+ );
946
+ }
947
+ }
948
+ const backend = entries[0]!.client;
949
+
950
+ const localToolMap = buildLocalToolMap(options.localTools);
951
+ const toolBackends = surfaceBackends(entries, 'tools');
952
+ const promptBackends = surfaceBackends(entries, 'prompts');
953
+ const resourceBackends = surfaceBackends(entries, 'resources');
954
+ const upstreamHasTools = toolBackends.entries.length > 0;
955
+
956
+ const capabilities = createProxyCapabilities(entries, localToolMap.size > 0);
957
+ const toolPipeline = pipe(options.toolMiddleware ?? []);
958
+ // Pass-through tools skip transforming middleware but are still seen by
959
+ // observers (audit, telemetry) marked via markPassThroughObserver().
960
+ const passThroughToolPipeline = pipe(
961
+ (options.toolMiddleware ?? []).filter(isPassThroughObserver),
962
+ );
963
+ const resourcePipeline = pipe(options.resourceMiddleware ?? []);
964
+ const promptPipeline = pipe(options.promptMiddleware ?? []);
965
+ const listToolsPipeline = pipe(options.listToolsMiddleware ?? []);
966
+
967
+ const isHiddenTool = toHiddenToolPredicate(options.hiddenTools);
968
+ // Applied uniformly at every handler entry, pass-through included: a
969
+ // privacy control a per-tool option could silently switch off would be
970
+ // the same false-confidence failure as the dispatcher bypass (ADR-0006).
971
+ const stripMeta = options.stripRequestMeta !== false;
972
+ const stripRequest = <
973
+ Req extends { params?: { _meta?: unknown } | undefined },
974
+ >(
975
+ req: Req,
976
+ ): Req => (stripMeta ? stripParamsMeta(req) : req);
977
+ // The response-direction mirror: applied to every upstream result at the
978
+ // innermost `next`, never to local tool results (they have no upstream).
979
+ const stripResMeta = options.stripResultMeta !== false;
980
+ const stripResult = <Res extends { _meta?: unknown }>(res: Res): Res =>
981
+ stripResMeta ? stripTopLevelMeta(res) : res;
982
+ const passThroughToolSet = new Set(options.passThroughTools ?? []);
983
+ const hiddenResourceSet = new Set(options.hiddenResources ?? []);
984
+ const passThroughResourceSet = new Set(options.passThroughResources ?? []);
985
+
986
+ // Stamped onto every ProxyContext so middleware and the audit trail can
987
+ // attribute events to this proxy instance (#85, ADR-0012). Frozen because
988
+ // the one object is aliased into the SDK Server, every context, and every
989
+ // audit event: a middleware mutating it would rewrite provenance already
990
+ // recorded elsewhere.
991
+ const proxyIdentity: ProxyIdentity = Object.freeze({
992
+ name: options.name,
993
+ version: options.version ?? VERSION,
994
+ });
995
+
996
+ const server = new Server(proxyIdentity, { capabilities });
997
+
998
+ registerListChangedForwarders(entries, server, capabilities);
999
+
1000
+ /**
1001
+ * Reports a backend that dropped out of a mesh list. Failure isolation is
1002
+ * only safe if the gap is visible, and a throwing sink must not turn a
1003
+ * degraded list into a failed one.
1004
+ */
1005
+ const reportDegraded = (
1006
+ context: ProxyContext,
1007
+ backendKey: string,
1008
+ method: BackendDegradedTelemetryEvent['method'],
1009
+ error: unknown,
1010
+ ): void => {
1011
+ callTelemetrySink(options.onTelemetry, {
1012
+ type: 'backend_degraded',
1013
+ requestId: context.requestId,
1014
+ ...(context.sessionId === undefined
1015
+ ? {}
1016
+ : { sessionId: context.sessionId }),
1017
+ backend: backendKey,
1018
+ method,
1019
+ error,
1020
+ ...(context.identity === undefined ? {} : { identity: context.identity }),
1021
+ ...(context.proxy === undefined ? {} : { proxy: context.proxy }),
1022
+ });
1023
+ };
1024
+
1025
+ // ── Tool handlers ──────────────────────────────────────────────────────────
1026
+
1027
+ if (capabilities.tools) {
1028
+ server.setRequestHandler(ListToolsRequestSchema, async (rawReq, extra) => {
1029
+ // Read the chain before the strip, or it never survives a chained
1030
+ // proxy (ADR-0016, the ADR-0011 boundary-ordering constraint).
1031
+ const { chain, error: wireError } = readInboundDelegation(rawReq);
1032
+ const req = stripRequest(rawReq);
1033
+ const requestOptions = createRequestOptions(extra);
1034
+ const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
1035
+ const delegationError = wireError ?? detectDelegationLoop(context);
1036
+ // Local tools are overlaid inside the innermost `next`, so
1037
+ // listToolsMiddleware sees them exactly as it sees upstream tools.
1038
+ // They are added to the first page only (no cursor), so a paginating
1039
+ // client sees each local tool once; the shadow filter runs on every
1040
+ // page, so the client sees exactly one entry per name.
1041
+ const result = await listToolsPipeline(
1042
+ req,
1043
+ async (currentReq) => {
1044
+ // Thrown inside the pipeline, so observing middleware records the
1045
+ // attempt rather than seeing a call that never happened.
1046
+ if (delegationError !== undefined) throw delegationError;
1047
+ const upstreamResult: ListToolsResult = mesh
1048
+ ? {
1049
+ // A mesh has no cursor that means "page 3 of the union", so
1050
+ // every backend is drained into one complete page.
1051
+ tools: await listAcrossMesh(
1052
+ toolBackends.entries,
1053
+ async (client, cursor) => {
1054
+ const page = stripResult(
1055
+ await client.listTools(
1056
+ attachDelegationMeta(
1057
+ cursor === undefined ? {} : { cursor },
1058
+ context,
1059
+ ),
1060
+ requestOptions,
1061
+ ),
1062
+ );
1063
+ return { items: page.tools, nextCursor: page.nextCursor };
1064
+ },
1065
+ (key, error) =>
1066
+ reportDegraded(context, key, 'tools/list', error),
1067
+ namespaceName,
1068
+ ),
1069
+ }
1070
+ : upstreamHasTools
1071
+ ? stripResult(
1072
+ await backend.listTools(
1073
+ attachDelegationMeta(currentReq.params, context),
1074
+ requestOptions,
1075
+ ),
1076
+ )
1077
+ : { tools: [] };
1078
+ if (!localToolMap.size) {
1079
+ return filterHiddenTools(upstreamResult, isHiddenTool);
1080
+ }
1081
+ const tools = [
1082
+ ...upstreamResult.tools.filter((t) => !localToolMap.has(t.name)),
1083
+ // A mesh response is always the only page, so local tools belong
1084
+ // on it whatever cursor the client sent.
1085
+ ...(mesh || currentReq.params?.cursor === undefined
1086
+ ? [...localToolMap.values()].map((lt) => lt.tool)
1087
+ : []),
1088
+ ];
1089
+ return filterHiddenTools({ ...upstreamResult, tools }, isHiddenTool);
1090
+ },
1091
+ context,
1092
+ );
1093
+
1094
+ return filterHiddenTools(result, isHiddenTool);
1095
+ });
1096
+
1097
+ server.setRequestHandler(CallToolRequestSchema, async (rawReq, extra) => {
1098
+ const { chain, error: wireError } = readInboundDelegation(rawReq);
1099
+ const req = stripRequest(rawReq);
1100
+ const name = req.params.name;
1101
+ const requestOptions = createRequestOptions(extra);
1102
+ const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
1103
+ const delegationError = wireError ?? detectDelegationLoop(context);
1104
+ const start = performance.now();
1105
+
1106
+ const emitTelemetry = (
1107
+ outcome: ToolCallTelemetryEvent['outcome'],
1108
+ rejectionReason?: RejectionReason,
1109
+ ) => {
1110
+ callTelemetrySink(options.onTelemetry, {
1111
+ type: 'tool_call',
1112
+ requestId: context.requestId,
1113
+ ...(context.sessionId === undefined
1114
+ ? {}
1115
+ : { sessionId: context.sessionId }),
1116
+ tool: name,
1117
+ duration_ms: Math.round(performance.now() - start),
1118
+ outcome,
1119
+ ...(rejectionReason === undefined ? {} : { rejectionReason }),
1120
+ ...(context.identity === undefined
1121
+ ? {}
1122
+ : { identity: context.identity }),
1123
+ ...(context.proxy === undefined ? {} : { proxy: context.proxy }),
1124
+ });
1125
+ };
1126
+
1127
+ // Hidden beats pass-through. The rejection is thrown by the innermost
1128
+ // `next` INSIDE the pipeline so middleware (audit) observes it in-chain;
1129
+ // the backend is never called for hidden tools.
1130
+ // Call phase: args is always an object, empty when the client sent
1131
+ // none, so a dispatcher-aware predicate can fail closed on it.
1132
+ const isHidden = isHiddenTool(name, req.params.arguments ?? {});
1133
+ // Hidden beats local; local beats upstream; pass-through never
1134
+ // applies to a local tool (it has no upstream response to forward).
1135
+ const localTool = isHidden ? undefined : localToolMap.get(name);
1136
+ const pipeline =
1137
+ !isHidden && localTool === undefined && passThroughToolSet.has(name)
1138
+ ? passThroughToolPipeline
1139
+ : toolPipeline;
1140
+ // The return type is annotated on the binding rather than on the arrow:
1141
+ // Babel, which Stryker uses to instrument this file for mutation testing,
1142
+ // cannot parse an async arrow carrying a return type inside a conditional.
1143
+ const callBackend: (
1144
+ r: CallToolRequest,
1145
+ ) => Promise<CompatibilityCallToolResult> = isHidden
1146
+ ? async () => {
1147
+ throw rejectionMcpError(
1148
+ 'TOOL_HIDDEN',
1149
+ ErrorCode.MethodNotFound,
1150
+ `Tool not found: ${name}`,
1151
+ );
1152
+ }
1153
+ : localTool !== undefined
1154
+ ? (r) => localTool.handler(r.params, context)
1155
+ : mesh
1156
+ ? async (r) => {
1157
+ // Routed at the innermost `next` from the post-pipeline name,
1158
+ // so middleware can re-route a call deliberately.
1159
+ const routed = routeNamespaced(
1160
+ r.params.name,
1161
+ toolBackends.byKey,
1162
+ );
1163
+ if (routed === undefined) {
1164
+ throw rejectionMcpError(
1165
+ 'BACKEND_UNROUTABLE',
1166
+ ErrorCode.MethodNotFound,
1167
+ `Tool not found: ${r.params.name} — a mesh exposes tools as "<backendKey>__<tool>"`,
1168
+ );
1169
+ }
1170
+ return stripResult(
1171
+ await routed.client.callTool(
1172
+ attachDelegationMeta(
1173
+ { ...r.params, name: routed.name },
1174
+ context,
1175
+ ),
1176
+ undefined,
1177
+ requestOptions,
1178
+ ),
1179
+ );
1180
+ }
1181
+ : upstreamHasTools
1182
+ ? async (r) =>
1183
+ stripResult(
1184
+ await backend.callTool(
1185
+ attachDelegationMeta(r.params, context),
1186
+ undefined,
1187
+ requestOptions,
1188
+ ),
1189
+ )
1190
+ : async () => {
1191
+ // Tools-less upstream: reject inside the pipeline rather
1192
+ // than forwarding to an upstream that cannot serve it.
1193
+ throw new McpError(
1194
+ ErrorCode.MethodNotFound,
1195
+ `Tool not found: ${name}`,
1196
+ );
1197
+ };
1198
+
1199
+ try {
1200
+ // A malformed chain replaces the innermost `next` rather than
1201
+ // rejecting at the boundary, so the pipeline observes it, and it is
1202
+ // decided before the tool is: the answer says nothing about the tool.
1203
+ const result = await pipeline(
1204
+ req,
1205
+ delegationError === undefined
1206
+ ? callBackend
1207
+ : () => Promise.reject(delegationError),
1208
+ context,
1209
+ );
1210
+ // MCP signals tool-level failures in-band via isError, not by throwing.
1211
+ // Three shapes reach here, and `hasToolContent` alone cannot tell them
1212
+ // apart because it answers false for two of them:
1213
+ //
1214
+ // - a CallToolResult: judge it by `isError`;
1215
+ // - the legacy `{ toolResult }` shape (protocol 2024-10-07): a valid
1216
+ // result with no `.content`, so `mapToolResult` passes it through —
1217
+ // a success;
1218
+ // - nothing at all, from a local tool with a missing `return` or a
1219
+ // middleware that dropped the result: the call produced no result,
1220
+ // so it is not a success.
1221
+ //
1222
+ // A result-less call used to reach the `catch` as a TypeError from
1223
+ // `hasToolContent` and be recorded as an error; now that the guard
1224
+ // answers false instead of throwing, the shape has to be distinguished
1225
+ // here or a `&&` would report it as a clean one.
1226
+ const isResultless =
1227
+ result === null || result === undefined || typeof result !== 'object';
1228
+ emitTelemetry(
1229
+ isResultless
1230
+ ? 'error'
1231
+ : hasToolContent(result) && result.isError === true
1232
+ ? 'error'
1233
+ : 'success',
1234
+ );
1235
+ return result;
1236
+ } catch (err) {
1237
+ const reason = getRejectionReason(err);
1238
+ if (reason === undefined) emitTelemetry('error');
1239
+ else emitTelemetry('rejected', reason);
1240
+ throw err;
1241
+ }
1242
+ });
1243
+ }
1244
+
1245
+ // ── Resource handlers ──────────────────────────────────────────────────────
1246
+
1247
+ if (capabilities.resources) {
1248
+ server.setRequestHandler(
1249
+ ListResourcesRequestSchema,
1250
+ async (rawReq, extra) => {
1251
+ const { chain, error: wireError } = readInboundDelegation(rawReq);
1252
+ const context = getMiddlewareContext(
1253
+ proxyIdentity,
1254
+ extra.signal,
1255
+ chain,
1256
+ );
1257
+ const delegationError = wireError ?? detectDelegationLoop(context);
1258
+ // `resources/list` runs no pipeline, so there is no observer to
1259
+ // record the attempt: the rejection is thrown here instead.
1260
+ if (delegationError !== undefined) throw delegationError;
1261
+ const requestOptions = createRequestOptions(extra);
1262
+ const result = mesh
1263
+ ? {
1264
+ // Each URI is exposed as `mcpose://<key>/<uri>` (ADR-0022), so
1265
+ // two backends serving the same URI never collide.
1266
+ resources: await listAcrossMesh(
1267
+ resourceBackends.entries,
1268
+ async (client, cursor) => {
1269
+ const page = stripResult(
1270
+ await client.listResources(
1271
+ attachDelegationMeta(
1272
+ cursor === undefined ? {} : { cursor },
1273
+ context,
1274
+ ),
1275
+ requestOptions,
1276
+ ),
1277
+ );
1278
+ return { items: page.resources, nextCursor: page.nextCursor };
1279
+ },
1280
+ (key, error) =>
1281
+ reportDegraded(context, key, 'resources/list', error),
1282
+ namespaceUri,
1283
+ ),
1284
+ }
1285
+ : stripResult(
1286
+ await backend.listResources(
1287
+ attachDelegationMeta(stripRequest(rawReq).params, context),
1288
+ requestOptions,
1289
+ ),
1290
+ );
1291
+ if (!hiddenResourceSet.size) return result;
1292
+ return {
1293
+ ...result,
1294
+ resources: result.resources.filter(
1295
+ (r) => !hiddenResourceSet.has(r.uri),
1296
+ ),
1297
+ };
1298
+ },
1299
+ );
1300
+
1301
+ server.setRequestHandler(ReadResourceRequestSchema, (rawReq, extra) => {
1302
+ const { chain, error: wireError } = readInboundDelegation(rawReq);
1303
+ const req = stripRequest(rawReq);
1304
+ const uri = req.params.uri;
1305
+ const requestOptions = createRequestOptions(extra);
1306
+ const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
1307
+ const delegationError = wireError ?? detectDelegationLoop(context);
1308
+
1309
+ if (hiddenResourceSet.has(uri)) {
1310
+ throw rejectionMcpError(
1311
+ 'RESOURCE_HIDDEN',
1312
+ ErrorCode.InvalidRequest,
1313
+ `Resource not found: ${uri}`,
1314
+ );
1315
+ }
1316
+ // In mesh mode the exposed URI is unwrapped to a backend and its own
1317
+ // URI; an unwrappable one is rejected loudly, with no lookup of which
1318
+ // backend listed it (ADR-0022). Resolved from the post-pipeline params
1319
+ // so middleware can re-route a read deliberately.
1320
+ const readUpstream = (
1321
+ params: ReadResourceRequest['params'],
1322
+ ): Promise<ReadResourceResult> => {
1323
+ if (delegationError !== undefined) throw delegationError;
1324
+ if (!mesh) {
1325
+ return backend
1326
+ .readResource(attachDelegationMeta(params, context), requestOptions)
1327
+ .then(stripResult);
1328
+ }
1329
+ const routed = routeResourceUri(params.uri, resourceBackends.byKey);
1330
+ if (routed === undefined) {
1331
+ throw rejectionMcpError(
1332
+ 'BACKEND_UNROUTABLE',
1333
+ ErrorCode.InvalidRequest,
1334
+ `Resource not found: ${params.uri} — a mesh exposes resources as "mcpose://<backendKey>/<uri>"`,
1335
+ );
1336
+ }
1337
+ return routed.client
1338
+ .readResource(
1339
+ attachDelegationMeta({ ...params, uri: routed.uri }, context),
1340
+ requestOptions,
1341
+ )
1342
+ .then(stripResult);
1343
+ };
1344
+
1345
+ if (passThroughResourceSet.has(uri)) {
1346
+ // A pass-through resource runs no pipeline at all, so as with
1347
+ // `resources/list` the rejection is thrown here.
1348
+ return readUpstream(req.params);
1349
+ }
1350
+ return resourcePipeline(req, (r) => readUpstream(r.params), context);
1351
+ });
1352
+ }
1353
+
1354
+ // ── Prompt handlers ───────────────────────────────────────────────────────
1355
+ // `prompts/get` runs the promptMiddleware pipeline; `prompts/list` is still
1356
+ // pass-through (ADR-0014).
1357
+
1358
+ if (capabilities.prompts && mesh) {
1359
+ // Prompt names are plain strings, so they namespace and route exactly
1360
+ // like tool names (ADR-0013).
1361
+ server.setRequestHandler(
1362
+ ListPromptsRequestSchema,
1363
+ async (rawReq, extra) => {
1364
+ const { chain, error: wireError } = readInboundDelegation(rawReq);
1365
+ const requestOptions = createRequestOptions(extra);
1366
+ const context = getMiddlewareContext(
1367
+ proxyIdentity,
1368
+ extra.signal,
1369
+ chain,
1370
+ );
1371
+ // No pipeline here either, so the rejection is thrown at the handler.
1372
+ const delegationError = wireError ?? detectDelegationLoop(context);
1373
+ if (delegationError !== undefined) throw delegationError;
1374
+ return {
1375
+ prompts: await listAcrossMesh(
1376
+ promptBackends.entries,
1377
+ async (client, cursor) => {
1378
+ const page = stripResult(
1379
+ await client.listPrompts(
1380
+ attachDelegationMeta(
1381
+ cursor === undefined ? {} : { cursor },
1382
+ context,
1383
+ ),
1384
+ requestOptions,
1385
+ ),
1386
+ );
1387
+ return { items: page.prompts, nextCursor: page.nextCursor };
1388
+ },
1389
+ (key, error) => reportDegraded(context, key, 'prompts/list', error),
1390
+ namespaceName,
1391
+ ),
1392
+ };
1393
+ },
1394
+ );
1395
+
1396
+ server.setRequestHandler(GetPromptRequestSchema, (rawReq, extra) => {
1397
+ const { chain, error: wireError } = readInboundDelegation(rawReq);
1398
+ const req = stripRequest(rawReq);
1399
+ const requestOptions = createRequestOptions(extra);
1400
+ const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
1401
+ const delegationError = wireError ?? detectDelegationLoop(context);
1402
+ // Routed at the innermost `next` from the post-pipeline name, so the
1403
+ // rejection is thrown in-chain and middleware (audit) observes it.
1404
+ return promptPipeline(
1405
+ req,
1406
+ async (r) => {
1407
+ if (delegationError !== undefined) throw delegationError;
1408
+ const routed = routeNamespaced(r.params.name, promptBackends.byKey);
1409
+ if (routed === undefined) {
1410
+ throw rejectionMcpError(
1411
+ 'BACKEND_UNROUTABLE',
1412
+ ErrorCode.MethodNotFound,
1413
+ `Prompt not found: ${r.params.name} — a mesh exposes prompts as "<backendKey>__<prompt>"`,
1414
+ );
1415
+ }
1416
+ return stripResult(
1417
+ await routed.client.getPrompt(
1418
+ attachDelegationMeta({ ...r.params, name: routed.name }, context),
1419
+ requestOptions,
1420
+ ),
1421
+ );
1422
+ },
1423
+ context,
1424
+ );
1425
+ });
1426
+ } else if (capabilities.prompts) {
1427
+ server.setRequestHandler(ListPromptsRequestSchema, (rawReq, extra) => {
1428
+ const { chain, error: wireError } = readInboundDelegation(rawReq);
1429
+ const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
1430
+ // No pipeline here either, so the rejection is thrown at the handler.
1431
+ const delegationError = wireError ?? detectDelegationLoop(context);
1432
+ if (delegationError !== undefined) throw delegationError;
1433
+ return backend
1434
+ .listPrompts(
1435
+ attachDelegationMeta(stripRequest(rawReq).params, context),
1436
+ createRequestOptions(extra),
1437
+ )
1438
+ .then(stripResult);
1439
+ });
1440
+
1441
+ server.setRequestHandler(GetPromptRequestSchema, (rawReq, extra) => {
1442
+ const { chain, error: wireError } = readInboundDelegation(rawReq);
1443
+ const requestOptions = createRequestOptions(extra);
1444
+ const context = getMiddlewareContext(proxyIdentity, extra.signal, chain);
1445
+ const delegationError = wireError ?? detectDelegationLoop(context);
1446
+ return promptPipeline(
1447
+ stripRequest(rawReq),
1448
+ async (r) => {
1449
+ if (delegationError !== undefined) throw delegationError;
1450
+ return stripResult(
1451
+ await backend.getPrompt(
1452
+ attachDelegationMeta(r.params, context),
1453
+ requestOptions,
1454
+ ),
1455
+ );
1456
+ },
1457
+ context,
1458
+ );
1459
+ });
1460
+ }
1461
+
1462
+ return server;
1463
+ }
1464
+
1465
+ /**
1466
+ * Starts the proxy on stdio.
1467
+ * Calls {@link createProxyServer} then connects to `StdioServerTransport`.
1468
+ * Use `createProxyServer` directly for testable access to the server.
1469
+ */
1470
+ export async function startProxy(
1471
+ backends: Backends,
1472
+ options: ProxyOptions,
1473
+ ): Promise<void> {
1474
+ const server = createProxyServer(backends, options);
1475
+ await server.connect(new StdioServerTransport());
1476
+ }
1477
+
1478
+ /**
1479
+ * Enforces `maxBodyBytes`.
1480
+ *
1481
+ * Fast path: a declared `Content-Length` over the limit is rejected before
1482
+ * any body is read. Chunked/streamed bodies are counted as they arrive.
1483
+ *
1484
+ * @returns `true` when the request was already rejected (caller must stop).
1485
+ */
1486
+ function applyBodySizeLimit(
1487
+ req: http.IncomingMessage,
1488
+ res: http.ServerResponse,
1489
+ maxBodyBytes: number,
1490
+ ): boolean {
1491
+ const reject413 = (): void => {
1492
+ if (!res.headersSent) {
1493
+ res.writeHead(413, { 'content-type': 'application/json' }).end(
1494
+ JSON.stringify({
1495
+ error: {
1496
+ message: 'Request body too large',
1497
+ data: { rejectionReason: 'BODY_LIMIT' },
1498
+ },
1499
+ }),
1500
+ );
1501
+ }
1502
+ };
1503
+
1504
+ const declared = Number(req.headers['content-length']);
1505
+ if (Number.isFinite(declared) && declared > maxBodyBytes) {
1506
+ reject413();
1507
+ req.destroy();
1508
+ return true;
1509
+ }
1510
+
1511
+ let total = 0;
1512
+ const originalPush = req.push.bind(req);
1513
+ (req as unknown as { push: typeof req.push }).push = (
1514
+ chunk: Buffer | string | null,
1515
+ enc?: BufferEncoding,
1516
+ ): boolean => {
1517
+ const chunkBytes =
1518
+ chunk === null
1519
+ ? 0
1520
+ : typeof chunk === 'string'
1521
+ ? Buffer.byteLength(chunk, enc)
1522
+ : chunk.length;
1523
+ if (chunk !== null && total + chunkBytes > maxBodyBytes) {
1524
+ reject413();
1525
+ if (res.headersSent) {
1526
+ // Too late for a clean 413 — sever the connection instead of
1527
+ // leaving the client on a hung socket.
1528
+ res.destroy();
1529
+ } else {
1530
+ // Mute the response for downstream writers (the SDK transport will
1531
+ // observe the destroyed request and try to send its own error).
1532
+ const muted = res as unknown as Record<string, unknown>;
1533
+ muted.writeHead = () => res;
1534
+ muted.write = () => true;
1535
+ muted.end = () => res;
1536
+ }
1537
+ req.destroy(new Error('Request body too large'));
1538
+ return false;
1539
+ }
1540
+ total += chunkBytes;
1541
+ return originalPush(chunk, enc);
1542
+ };
1543
+ return false;
1544
+ }
1545
+
1546
+ /**
1547
+ * True for bind addresses only reachable from the local machine.
1548
+ * `127.` catches the whole 127.0.0.0/8 block Node can bind.
1549
+ */
1550
+ function isLoopbackHost(host: string): boolean {
1551
+ return (
1552
+ host === 'localhost' ||
1553
+ host === '::1' ||
1554
+ host === '::ffff:127.0.0.1' ||
1555
+ host.startsWith('127.')
1556
+ );
1557
+ }
1558
+
1559
+ const MAX_NODE_TIMER_DELAY_MS = 2 ** 31 - 1;
1560
+
1561
+ /**
1562
+ * Starts the proxy over Streamable HTTP with stateful sessions.
1563
+ *
1564
+ * Sessions keyed by `mcp-session-id`. Upstream notifications fanned out to
1565
+ * all active sessions via their GET SSE stream. Dropped connections can
1566
+ * replay missed notifications via the built-in in-memory EventStore (or a
1567
+ * custom persistent store for multi-instance deployments).
1568
+ *
1569
+ * @returns Promise resolving to the listening `http.Server` (or `https.Server`
1570
+ * when `tlsOptions` is supplied).
1571
+ */
1572
+ export function startHttpProxy(
1573
+ backends: Backends,
1574
+ options: ProxyOptions,
1575
+ httpOptions: HttpProxyOptions = {},
1576
+ ): Promise<http.Server> {
1577
+ // Sessions build their proxy server lazily, so surface configuration
1578
+ // errors (a blank name, duplicate local tool names, invalid backend keys)
1579
+ // at startup, not on first initialize.
1580
+ assertProxyName(options.name);
1581
+ buildLocalToolMap(options.localTools);
1582
+ normalizeBackends(backends);
1583
+
1584
+ const mcpPath = httpOptions.path ?? '/mcp';
1585
+ const port = httpOptions.port ?? 3000;
1586
+ const host = httpOptions.host ?? '127.0.0.1';
1587
+ const loopback = isLoopbackHost(host);
1588
+ const dnsRebindingProtection =
1589
+ httpOptions.enableDnsRebindingProtection ?? loopback;
1590
+ // Bounded by default (#107): unlimited sessions that never expire are a
1591
+ // memory exhaustion path for any client that can initialize. `Infinity`
1592
+ // is the opt-out for both; `maxSessions: 0` already means "reject every
1593
+ // session", so it cannot double as one. The `!(x >= n)` form also
1594
+ // rejects NaN, which a plain `x < n` would let through.
1595
+ const maxSessions = httpOptions.maxSessions ?? 1000;
1596
+ const sessionTtlMs = httpOptions.sessionTtlMs ?? 30 * 60 * 1000;
1597
+ if (!(maxSessions >= 0)) {
1598
+ throw new Error(
1599
+ `mcpose: maxSessions must be >= 0 or Infinity, got ${String(httpOptions.maxSessions)}`,
1600
+ );
1601
+ }
1602
+ if (!(sessionTtlMs > 0)) {
1603
+ throw new Error(
1604
+ `mcpose: sessionTtlMs must be > 0 or Infinity, got ${String(httpOptions.sessionTtlMs)}`,
1605
+ );
1606
+ }
1607
+ if (Number.isFinite(sessionTtlMs) && sessionTtlMs > MAX_NODE_TIMER_DELAY_MS) {
1608
+ throw new Error(
1609
+ `mcpose: sessionTtlMs must be <= ${MAX_NODE_TIMER_DELAY_MS} or Infinity, got ${String(httpOptions.sessionTtlMs)}`,
1610
+ );
1611
+ }
1612
+ // Resolved once, like the two limits above, so a bad value fails at startup
1613
+ // instead of quietly gating nothing. A `NaN` cap compares false against both
1614
+ // the declared `Content-Length` and the running byte count, so a body far
1615
+ // larger than the configured limit passes this layer untouched.
1616
+ // `Number(process.env.MAX_BODY_BYTES)` is the ordinary way a host wires an
1617
+ // operator-supplied limit in, and it is NaN when the key is unset or
1618
+ // misspelled; #264 rejected a non-finite `budget.maxCallsPerSession` for
1619
+ // exactly this reason. `Infinity` opts out, and `0`, like `maxSessions: 0`,
1620
+ // keeps its literal meaning instead of doubling as one.
1621
+ const maxBodyBytes = httpOptions.maxBodyBytes ?? 4 * 1024 * 1024;
1622
+ if (!(maxBodyBytes >= 0)) {
1623
+ throw new Error(
1624
+ `mcpose: maxBodyBytes must be >= 0 or Infinity, got ${String(httpOptions.maxBodyBytes)}`,
1625
+ );
1626
+ }
1627
+ // Filled in once the server is listening (the real port is only known
1628
+ // then); sessions are only created after that. The SDK transport
1629
+ // validates Host/Origin only against a non-empty list, so an enabled flag
1630
+ // without a list would be an inert control — see #74.
1631
+ let derivedAllowedHosts: string[] | undefined;
1632
+ let derivedAllowedOrigins: string[] | undefined;
1633
+ const eventStore =
1634
+ httpOptions.eventStore === null
1635
+ ? undefined
1636
+ : (httpOptions.eventStore ?? createInMemoryEventStore());
1637
+ const registry = httpOptions.sessionRegistry;
1638
+
1639
+ interface Session {
1640
+ transport: StreamableHTTPServerTransport;
1641
+ proxyServer: Server;
1642
+ identity?: Identity;
1643
+ ttlTimer?: NodeJS.Timeout;
1644
+ }
1645
+ // session ID → live session held by this instance
1646
+ const sessions = new Map<string, Session>();
1647
+
1648
+ // Sessions being initialized or resumed (identity resolution or registry
1649
+ // lookup in flight) — counted so concurrent admissions cannot overshoot
1650
+ // maxSessions.
1651
+ let pendingSessions = 0;
1652
+
1653
+ const reportError = (err: unknown): void => {
1654
+ // `onError` is observability, so its own failure must change nothing, the
1655
+ // same containment `onAuditError` applies to `@mcpose/audit`'s reporter
1656
+ // (#172) and `createConsentMiddleware` applies to its resolver hook. Left
1657
+ // unguarded, a throwing reporter replaced the 401 this file documents for
1658
+ // a failed auth hook with a 500, aborted `destroySession` before it closed
1659
+ // the proxy server, and threw out of the `server.on('error')` handler
1660
+ // that exists precisely so a post-listen error does not crash the process.
1661
+ // An async reporter that rejects after its first await escapes the same
1662
+ // guard one tick later (#245): nobody observes the rejection, and Node's
1663
+ // default --unhandled-rejections=throw ends the process, so the returned
1664
+ // promise is adopted here too.
1665
+ try {
1666
+ Promise.resolve((httpOptions.onError ?? console.error)(err)).catch(() => {
1667
+ // Nothing left to report it to.
1668
+ });
1669
+ } catch {
1670
+ // Nothing left to report it to.
1671
+ }
1672
+ };
1673
+
1674
+ /**
1675
+ * Single teardown path for every way a session can end: client DELETE,
1676
+ * TTL expiry, and server shutdown. Clears the TTL timer, drops the shared
1677
+ * record unless the session is meant to outlive this process, fires and
1678
+ * awaits `onSessionClosed` (guarded — a throwing or rejecting hook must not
1679
+ * break teardown), and closes the proxy server so it leaves the listChanged
1680
+ * fan-out bus. Idempotent: a second call for the same id is a no-op.
1681
+ */
1682
+ const destroySession = async (
1683
+ id: string,
1684
+ keepRecord = false,
1685
+ ): Promise<void> => {
1686
+ const session = sessions.get(id);
1687
+ if (!session) return;
1688
+ sessions.delete(id);
1689
+ if (session.ttlTimer !== undefined) clearTimeout(session.ttlTimer);
1690
+ if (registry && !keepRecord) await registry.delete(id).catch(reportError);
1691
+ try {
1692
+ await httpOptions.onSessionClosed?.(id);
1693
+ } catch (err) {
1694
+ reportError(err);
1695
+ }
1696
+ await session.proxyServer.close().catch(reportError);
1697
+ };
1698
+
1699
+ /**
1700
+ * Takes a session live on this instance. `expiresAt` is the session's
1701
+ * deadline fixed at creation, so a resumed session gets the remaining
1702
+ * lifetime rather than a fresh TTL; `undefined` means it never expires.
1703
+ */
1704
+ const admitSession = (
1705
+ id: string,
1706
+ transport: StreamableHTTPServerTransport,
1707
+ proxyServer: Server,
1708
+ identity: Identity | undefined,
1709
+ expiresAt: number | undefined,
1710
+ ): void => {
1711
+ let ttlTimer: NodeJS.Timeout | undefined;
1712
+ if (expiresAt !== undefined) {
1713
+ // A record from a proxy with a longer TTL may still exceed Node's
1714
+ // timer ceiling; clamping only makes that timer fire early and
1715
+ // re-check nothing, since the session is then simply closed.
1716
+ const remaining = Math.min(
1717
+ Math.max(expiresAt - Date.now(), 0),
1718
+ MAX_NODE_TIMER_DELAY_MS,
1719
+ );
1720
+ ttlTimer = setTimeout(() => {
1721
+ void destroySession(id);
1722
+ }, remaining);
1723
+ ttlTimer.unref();
1724
+ }
1725
+ sessions.set(id, {
1726
+ transport,
1727
+ proxyServer,
1728
+ ...(identity === undefined ? {} : { identity }),
1729
+ ...(ttlTimer === undefined ? {} : { ttlTimer }),
1730
+ });
1731
+ };
1732
+
1733
+ const sessionLimitBody = JSON.stringify({
1734
+ error: {
1735
+ message: 'Session limit reached',
1736
+ data: { rejectionReason: 'SESSION_LIMIT' },
1737
+ },
1738
+ });
1739
+
1740
+ // Builds the transport for one session. The id is fixed before the
1741
+ // transport exists so the event store can be scoped to it: stream ids are
1742
+ // namespaced per session (#154).
1743
+ const createTransport = (
1744
+ sessionId: string,
1745
+ onsessioninitialized: (id: string) => void | Promise<void>,
1746
+ ): StreamableHTTPServerTransport => {
1747
+ // An explicit list is used verbatim; the derived loopback list only
1748
+ // fills the gap so the default actually validates something.
1749
+ const allowedHosts = httpOptions.allowedHosts ?? derivedAllowedHosts;
1750
+ const allowedOrigins = httpOptions.allowedOrigins ?? derivedAllowedOrigins;
1751
+ return new StreamableHTTPServerTransport({
1752
+ sessionIdGenerator: () => sessionId,
1753
+ ...(eventStore
1754
+ ? { eventStore: scopeEventStore(eventStore, sessionId) }
1755
+ : {}),
1756
+ ...(allowedHosts ? { allowedHosts } : {}),
1757
+ ...(allowedOrigins ? { allowedOrigins } : {}),
1758
+ enableDnsRebindingProtection: dnsRebindingProtection,
1759
+ onsessioninitialized,
1760
+ onsessionclosed: (id) => {
1761
+ void destroySession(id);
1762
+ },
1763
+ });
1764
+ };
1765
+
1766
+ // Resumes in flight, so concurrent requests for one unknown id build one
1767
+ // session between them instead of one each.
1768
+ const resuming = new Map<
1769
+ string,
1770
+ Promise<Session | { status: number; body: string } | undefined>
1771
+ >();
1772
+
1773
+ /**
1774
+ * Rebuilds a session this instance does not hold from its shared record:
1775
+ * `undefined` when the registry has no live record for it, or the SDK's
1776
+ * own rejection when the replayed initialize did not bring the transport
1777
+ * up (a failed Host or Origin check, which the real request would fail
1778
+ * identically).
1779
+ */
1780
+ const resumeSession = (
1781
+ id: string,
1782
+ sharedRegistry: SessionRegistry,
1783
+ req: http.IncomingMessage,
1784
+ ): Promise<Session | { status: number; body: string } | undefined> => {
1785
+ const inflight = resuming.get(id);
1786
+ if (inflight) return inflight;
1787
+ pendingSessions += 1;
1788
+ const attempt = (async () => {
1789
+ const record = await sharedRegistry.get(id);
1790
+ if (
1791
+ record === undefined ||
1792
+ (record.expiresAt !== undefined && record.expiresAt <= Date.now())
1793
+ ) {
1794
+ return undefined;
1795
+ }
1796
+ // The pending slot is already counted, so this instance can be full
1797
+ // by exactly one.
1798
+ if (sessions.size + pendingSessions > maxSessions) {
1799
+ return { status: 503, body: sessionLimitBody };
1800
+ }
1801
+ const proxyServer = createProxyServer(backends, options);
1802
+ const transport = createTransport(id, (sid) => {
1803
+ admitSession(
1804
+ sid,
1805
+ transport,
1806
+ proxyServer,
1807
+ record.identity,
1808
+ record.expiresAt,
1809
+ );
1810
+ });
1811
+ try {
1812
+ await proxyServer.connect(transport as Transport);
1813
+ const answer = await replayInitialize(transport, record.initialize, {
1814
+ ...(req.headers.host === undefined ? {} : { host: req.headers.host }),
1815
+ ...(req.headers.origin === undefined
1816
+ ? {}
1817
+ : { origin: req.headers.origin }),
1818
+ });
1819
+ return sessions.get(id) ?? answer;
1820
+ } finally {
1821
+ // Not admitted, whether the SDK refused the replay or threw: close
1822
+ // the orphaned proxy server before it leaks (memory + listChanged
1823
+ // fan-out), as the fresh-session path does.
1824
+ if (!sessions.has(id)) void proxyServer.close().catch(reportError);
1825
+ }
1826
+ })().finally(() => {
1827
+ resuming.delete(id);
1828
+ pendingSessions -= 1;
1829
+ });
1830
+ resuming.set(id, attempt);
1831
+ return attempt;
1832
+ };
1833
+
1834
+ const requestHandler = (
1835
+ req: http.IncomingMessage,
1836
+ res: http.ServerResponse,
1837
+ ) => {
1838
+ const handle = async () => {
1839
+ if (httpOptions.onRequest !== undefined) {
1840
+ let allowed: boolean;
1841
+ try {
1842
+ allowed = await httpOptions.onRequest(req, res);
1843
+ } catch (err) {
1844
+ // Failing closed is the right answer for the caller; it is not an
1845
+ // answer for the operator, whose only signal would otherwise be a
1846
+ // stream of 401s. Same containment the session teardown path
1847
+ // already uses for a throwing `onSessionClosed`.
1848
+ reportError(err);
1849
+ if (!res.headersSent) res.writeHead(401).end();
1850
+ return;
1851
+ }
1852
+ if (!allowed) return;
1853
+ }
1854
+
1855
+ const url = new URL(req.url ?? '/', 'http://localhost');
1856
+ const method = req.method ?? '';
1857
+
1858
+ if (
1859
+ url.pathname !== mcpPath ||
1860
+ !['GET', 'POST', 'DELETE'].includes(method)
1861
+ ) {
1862
+ res.writeHead(404).end();
1863
+ return;
1864
+ }
1865
+
1866
+ if (method === 'POST') {
1867
+ const rejected = applyBodySizeLimit(req, res, maxBodyBytes);
1868
+ if (rejected) return;
1869
+ }
1870
+
1871
+ const sessionId = req.headers['mcp-session-id'];
1872
+ const headers = normalizeHeaders(req.headers);
1873
+
1874
+ if (typeof sessionId === 'string') {
1875
+ // Route to an existing session, resuming it from the shared registry
1876
+ // if another process created it — stamp its resolved identity into
1877
+ // context.
1878
+ const session =
1879
+ sessions.get(sessionId) ??
1880
+ (registry
1881
+ ? await resumeSession(sessionId, registry, req)
1882
+ : undefined);
1883
+ if (!session) {
1884
+ res.writeHead(404).end();
1885
+ return;
1886
+ }
1887
+ if ('status' in session) {
1888
+ res
1889
+ .writeHead(session.status, { 'content-type': 'application/json' })
1890
+ .end(session.body);
1891
+ return;
1892
+ }
1893
+ if (httpOptions.validateSession !== undefined) {
1894
+ let valid: boolean;
1895
+ try {
1896
+ valid = await httpOptions.validateSession(req, {
1897
+ sessionId,
1898
+ ...(session.identity === undefined
1899
+ ? {}
1900
+ : { identity: session.identity }),
1901
+ });
1902
+ } catch (err) {
1903
+ reportError(err);
1904
+ valid = false;
1905
+ }
1906
+ if (!valid) {
1907
+ if (!res.headersSent) res.writeHead(401).end();
1908
+ return;
1909
+ }
1910
+ }
1911
+ const requestContext: Omit<ProxyContext, 'requestId'> = {
1912
+ transport: 'http',
1913
+ sessionId,
1914
+ ...(headers === undefined ? {} : { headers }),
1915
+ ...(session.identity === undefined
1916
+ ? {}
1917
+ : { identity: session.identity }),
1918
+ };
1919
+ await httpProxyContext.run(requestContext, () =>
1920
+ session.transport.handleRequest(req, res),
1921
+ );
1922
+ } else {
1923
+ // New session — only an initialize POST may create one. A
1924
+ // session-less GET/DELETE can never initialize, so reject it
1925
+ // before constructing a Server/transport (which would otherwise
1926
+ // leak into the listChanged fan-out bus).
1927
+ if (method !== 'POST') {
1928
+ res.writeHead(400, { 'content-type': 'application/json' }).end(
1929
+ JSON.stringify({
1930
+ jsonrpc: '2.0',
1931
+ error: {
1932
+ code: -32000,
1933
+ message: 'Bad Request: no valid session ID provided',
1934
+ },
1935
+ id: null,
1936
+ }),
1937
+ );
1938
+ return;
1939
+ }
1940
+
1941
+ // Count this initialize as pending so concurrent initializes
1942
+ // cannot overshoot maxSessions while identity resolution awaits.
1943
+ if (sessions.size + pendingSessions >= maxSessions) {
1944
+ res
1945
+ .writeHead(503, { 'content-type': 'application/json' })
1946
+ .end(sessionLimitBody);
1947
+ return;
1948
+ }
1949
+ pendingSessions += 1;
1950
+
1951
+ // The shared record carries the client's initialize params, and the
1952
+ // SDK owns parsing the body, so tap the bytes as they arrive rather
1953
+ // than reading the stream out from under it. The same seam the body
1954
+ // limit uses.
1955
+ const bodyChunks: Buffer[] = [];
1956
+ if (registry) {
1957
+ const push = req.push.bind(req);
1958
+ (req as unknown as { push: typeof req.push }).push = (
1959
+ chunk: Buffer | string | null,
1960
+ enc?: BufferEncoding,
1961
+ ): boolean => {
1962
+ if (chunk !== null) {
1963
+ bodyChunks.push(
1964
+ typeof chunk === 'string' ? Buffer.from(chunk, enc) : chunk,
1965
+ );
1966
+ }
1967
+ return push(chunk, enc);
1968
+ };
1969
+ }
1970
+
1971
+ try {
1972
+ // Resolve identity once for the lifetime of this session
1973
+ let identity: Identity | undefined;
1974
+ if (httpOptions.resolveIdentity !== undefined) {
1975
+ try {
1976
+ identity = await httpOptions.resolveIdentity(req);
1977
+ } catch (err) {
1978
+ reportError(err);
1979
+ if (!res.headersSent) res.writeHead(401).end();
1980
+ return;
1981
+ }
1982
+ }
1983
+
1984
+ const proxyServer = createProxyServer(backends, options);
1985
+ const sessionId = randomUUID();
1986
+ const transport = createTransport(sessionId, async (id) => {
1987
+ // The deadline is fixed here and travels with the record, so a
1988
+ // resume elsewhere inherits what is left of it. Infinity opts
1989
+ // out; setTimeout would clamp it to 1ms.
1990
+ const expiresAt = Number.isFinite(sessionTtlMs)
1991
+ ? Date.now() + sessionTtlMs
1992
+ : undefined;
1993
+ // The SDK awaits this hook before it answers, so the record is
1994
+ // durable before the client can present the id anywhere. A
1995
+ // throw here becomes the SDK's 400 with the error text in the
1996
+ // body, so the real cause goes to onError and the client gets a
1997
+ // message that names nothing about the backing store.
1998
+ if (registry) {
1999
+ try {
2000
+ await registry.set(id, {
2001
+ initialize: initializeParamsOf(
2002
+ Buffer.concat(bodyChunks).toString(),
2003
+ ),
2004
+ ...(identity === undefined ? {} : { identity }),
2005
+ ...(expiresAt === undefined ? {} : { expiresAt }),
2006
+ });
2007
+ } catch (err) {
2008
+ reportError(err);
2009
+ throw new Error('mcpose: session registry unavailable');
2010
+ }
2011
+ }
2012
+ admitSession(id, transport, proxyServer, identity, expiresAt);
2013
+ });
2014
+
2015
+ const requestContext: Omit<ProxyContext, 'requestId'> = {
2016
+ transport: 'http',
2017
+ ...(headers === undefined ? {} : { headers }),
2018
+ ...(identity === undefined ? {} : { identity }),
2019
+ };
2020
+ // See backendClient.ts: the SDK's transports are not assignable to
2021
+ // its own `Transport` interface under `exactOptionalPropertyTypes`
2022
+ // (here `onclose?: () => void` vs `onclose: (() => void) | undefined`).
2023
+ await proxyServer.connect(transport as Transport);
2024
+ try {
2025
+ await httpProxyContext.run(requestContext, () =>
2026
+ transport.handleRequest(req, res),
2027
+ );
2028
+ } finally {
2029
+ // No session came up — a non-initialize body the transport
2030
+ // rejected, or a registry that refused the record — so close the
2031
+ // orphaned proxy server before it leaks (memory + listChanged
2032
+ // fan-out).
2033
+ if (!sessions.has(sessionId)) {
2034
+ void proxyServer.close().catch(reportError);
2035
+ }
2036
+ }
2037
+ } finally {
2038
+ pendingSessions -= 1;
2039
+ }
2040
+ }
2041
+ };
2042
+
2043
+ handle().catch((err) => {
2044
+ if (!res.headersSent) res.writeHead(500).end();
2045
+ reportError(err);
2046
+ });
2047
+ };
2048
+
2049
+ const server: http.Server = httpOptions.tlsOptions
2050
+ ? https.createServer(httpOptions.tlsOptions, requestHandler)
2051
+ : http.createServer(requestHandler);
2052
+
2053
+ const rawClose = server.close.bind(server);
2054
+ let shuttingDown = false;
2055
+
2056
+ server.close = (callback?: (err?: Error) => void) => {
2057
+ if (shuttingDown) return rawClose(callback);
2058
+ shuttingDown = true;
2059
+
2060
+ // Tear down every session through the single teardown path (clears TTL
2061
+ // timers, fires and awaits onSessionClosed so audit manifests flush
2062
+ // before the close callback runs).
2063
+ // Shared records are kept: the sessions are meant to resume on the next
2064
+ // process, and their deadlines expire them on their own.
2065
+ void Promise.allSettled(
2066
+ [...sessions.keys()].map((id) => destroySession(id, true)),
2067
+ ).finally(() => {
2068
+ rawClose(callback);
2069
+ // Idle keep-alive and lingering SSE sockets would otherwise keep the
2070
+ // close callback pending indefinitely.
2071
+ server.closeAllConnections();
2072
+ });
2073
+
2074
+ return server;
2075
+ };
2076
+
2077
+ if (!loopback && httpOptions.resolveIdentity === undefined) {
2078
+ reportError(
2079
+ new Error(
2080
+ `mcpose: startHttpProxy is binding non-loopback address "${host}" without resolveIdentity — ` +
2081
+ 'anything that can route to this host can call the upstream with ' +
2082
+ "the proxy's credentials. Pass resolveIdentity, or bind 127.0.0.1.",
2083
+ ),
2084
+ );
2085
+ }
2086
+ // The SDK transport validates Host/Origin only against a non-empty list,
2087
+ // and no list is derived for a non-loopback bind, so this combination is
2088
+ // an inert flag — the false-confidence case the derived defaults exist
2089
+ // to remove.
2090
+ if (
2091
+ dnsRebindingProtection &&
2092
+ !loopback &&
2093
+ httpOptions.allowedHosts === undefined &&
2094
+ httpOptions.allowedOrigins === undefined
2095
+ ) {
2096
+ reportError(
2097
+ new Error(
2098
+ 'mcpose: enableDnsRebindingProtection is on, but no allowedHosts or ' +
2099
+ 'allowedOrigins are set and none are derived for a non-loopback ' +
2100
+ 'bind, so the transport validates nothing. Pass allowedHosts ' +
2101
+ 'and/or allowedOrigins.',
2102
+ ),
2103
+ );
2104
+ }
2105
+
2106
+ return new Promise((resolve, reject) => {
2107
+ server.once('error', reject);
2108
+ server.listen(port, host, () => {
2109
+ server.off('error', reject);
2110
+ // Route post-listen server errors (e.g. EMFILE) to onError instead of
2111
+ // crashing the process with an unhandled 'error' event.
2112
+ server.on('error', reportError);
2113
+ if (dnsRebindingProtection && loopback) {
2114
+ const addr = server.address();
2115
+ const actualPort =
2116
+ addr !== null && typeof addr === 'object' ? addr.port : port;
2117
+ const hosts = [
2118
+ `127.0.0.1:${actualPort}`,
2119
+ `localhost:${actualPort}`,
2120
+ `[::1]:${actualPort}`,
2121
+ ];
2122
+ const scheme = httpOptions.tlsOptions ? 'https' : 'http';
2123
+ derivedAllowedHosts = hosts;
2124
+ derivedAllowedOrigins = hosts.map((h) => `${scheme}://${h}`);
2125
+ }
2126
+ resolve(server);
2127
+ });
2128
+ });
2129
+ }