mcp-proxy 6.5.4 → 6.5.5

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.
package/jsr.json CHANGED
@@ -3,5 +3,5 @@
3
3
  "include": ["src/index.ts", "src/bin/mcp-proxy.ts"],
4
4
  "license": "MIT",
5
5
  "name": "@punkpeye/mcp-proxy",
6
- "version": "6.5.4"
6
+ "version": "6.5.5"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-proxy",
3
- "version": "6.5.4",
3
+ "version": "6.5.5",
4
4
  "main": "dist/index.mjs",
5
5
  "scripts": {
6
6
  "build": "tsdown",
@@ -237,4 +237,100 @@ describe("InMemoryEventStore", () => {
237
237
  secondSpy.mockRestore();
238
238
  }
239
239
  });
240
+
241
+ it("evicts the oldest events once maxEvents is exceeded", async () => {
242
+ const store = new InMemoryEventStore({ maxEvents: 3 });
243
+ const streamId = "bounded-stream";
244
+
245
+ const eventIds: string[] = [];
246
+ for (let i = 0; i < 5; i++) {
247
+ eventIds.push(
248
+ await store.storeEvent(streamId, {
249
+ id: i,
250
+ jsonrpc: "2.0",
251
+ method: `step/${i}`,
252
+ }),
253
+ );
254
+ await new Promise((resolve) => setTimeout(resolve, 1));
255
+ }
256
+
257
+ // Only the 3 most recently stored events survive.
258
+ expect(store.size).toBe(3);
259
+
260
+ // The two oldest events were evicted, so resuming from them is no
261
+ // longer possible - the caller falls back to a fresh stream.
262
+ for (const evictedId of eventIds.slice(0, 2)) {
263
+ const result = await store.replayEventsAfter(evictedId, {
264
+ send: vi.fn(),
265
+ });
266
+ expect(result).toBe("");
267
+ }
268
+
269
+ // Resuming from a surviving event still replays what came after it.
270
+ const replayed: JSONRPCMessage[] = [];
271
+ const returnedStreamId = await store.replayEventsAfter(eventIds[2], {
272
+ send: async (_eventId, message) => {
273
+ replayed.push(message);
274
+ },
275
+ });
276
+ expect(returnedStreamId).toBe(streamId);
277
+ expect(replayed).toHaveLength(2);
278
+ });
279
+
280
+ it("caps total retained events across all streams, not per stream", async () => {
281
+ const store = new InMemoryEventStore({ maxEvents: 4 });
282
+
283
+ for (let i = 0; i < 3; i++) {
284
+ await store.storeEvent("stream-a", {
285
+ id: i,
286
+ jsonrpc: "2.0",
287
+ method: `a/${i}`,
288
+ });
289
+ await new Promise((resolve) => setTimeout(resolve, 1));
290
+ }
291
+ for (let i = 0; i < 3; i++) {
292
+ await store.storeEvent("stream-b", {
293
+ id: i,
294
+ jsonrpc: "2.0",
295
+ method: `b/${i}`,
296
+ });
297
+ await new Promise((resolve) => setTimeout(resolve, 1));
298
+ }
299
+
300
+ // 6 events were stored across two streams; the store-wide cap still holds.
301
+ expect(store.size).toBe(4);
302
+ });
303
+
304
+ it("defaults to a bounded size when maxEvents is not configured", async () => {
305
+ const store = new InMemoryEventStore();
306
+ const streamId = "default-cap-stream";
307
+
308
+ for (let i = 0; i < 1010; i++) {
309
+ await store.storeEvent(streamId, {
310
+ id: i,
311
+ jsonrpc: "2.0",
312
+ method: "tools/list",
313
+ });
314
+ }
315
+
316
+ expect(store.size).toBe(1000);
317
+ });
318
+
319
+ it("rejects a non-positive maxEvents", () => {
320
+ expect(() => new InMemoryEventStore({ maxEvents: 0 })).toThrow(
321
+ /maxEvents must be at least 1/,
322
+ );
323
+ expect(() => new InMemoryEventStore({ maxEvents: -1 })).toThrow(
324
+ /maxEvents must be at least 1/,
325
+ );
326
+ });
327
+
328
+ it("rejects a NaN maxEvents instead of silently disabling eviction", () => {
329
+ // A naive `maxEvents < 1` check lets NaN through (every comparison with
330
+ // NaN is false), which would make `size > maxEvents` never trigger and
331
+ // silently reintroduce unbounded growth.
332
+ expect(() => new InMemoryEventStore({ maxEvents: NaN })).toThrow(
333
+ /maxEvents must be at least 1/,
334
+ );
335
+ });
240
336
  });
@@ -6,17 +6,56 @@
6
6
  import type { EventStore } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
7
  import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
8
8
 
9
+ // A stream accumulates one event per server->client message (e.g. every
10
+ // tool call on a stateful session opens its own stream). With no cap, a
11
+ // single long-lived session climbs without bound - see
12
+ // https://github.com/punkpeye/mcp-proxy/issues/72.
13
+ const DEFAULT_MAX_EVENTS = 1000;
14
+
15
+ export interface InMemoryEventStoreOptions {
16
+ /**
17
+ * Maximum number of events retained across all streams before the oldest
18
+ * are evicted (FIFO). Bounds memory use; older reconnect attempts beyond
19
+ * this window fall back to a fresh (non-resumed) stream instead of a replay.
20
+ *
21
+ * @default 1000
22
+ */
23
+ maxEvents?: number;
24
+ }
25
+
9
26
  /**
10
27
  * Simple in-memory implementation of the EventStore interface for resumability
11
28
  * This is primarily intended for examples and testing, not for production use
12
29
  * where a persistent storage solution would be more appropriate.
13
30
  */
14
31
  export class InMemoryEventStore implements EventStore {
32
+ /**
33
+ * The number of events currently retained (for observability/testing).
34
+ */
35
+ get size(): number {
36
+ return this.events.size;
37
+ }
15
38
  private events: Map<string, { message: JSONRPCMessage; streamId: string }> =
16
39
  new Map();
17
40
  private lastTimestamp = 0;
18
41
  private lastTimestampCounter = 0;
19
42
 
43
+ private readonly maxEvents: number;
44
+
45
+ constructor(options: InMemoryEventStoreOptions = {}) {
46
+ const maxEvents = options.maxEvents ?? DEFAULT_MAX_EVENTS;
47
+
48
+ // Written as `!(maxEvents >= 1)` rather than `maxEvents < 1` so NaN
49
+ // (every comparison with NaN is false) is rejected too - otherwise
50
+ // `size > maxEvents` would never trigger eviction and silently
51
+ // reintroduce unbounded growth.
52
+ if (!(maxEvents >= 1)) {
53
+ throw new Error("maxEvents must be at least 1");
54
+ }
55
+
56
+ this.maxEvents = maxEvents;
57
+ }
58
+
20
59
  /**
21
60
  * Replays events that occurred after a specific event ID
22
61
  * Implements EventStore.replayEventsAfter
@@ -77,6 +116,18 @@ export class InMemoryEventStore implements EventStore {
77
116
 
78
117
  this.events.set(eventId, { message, streamId });
79
118
 
119
+ // Map iterates in insertion order, so the first key is always the
120
+ // oldest surviving event - evicting it is a plain FIFO/ring buffer.
121
+ while (this.events.size > this.maxEvents) {
122
+ const oldestEventId = this.events.keys().next().value;
123
+
124
+ if (oldestEventId === undefined) {
125
+ break;
126
+ }
127
+
128
+ this.events.delete(oldestEventId);
129
+ }
130
+
80
131
  return eventId;
81
132
  }
82
133
 
@@ -14,7 +14,6 @@ import { hideBin } from "yargs/helpers";
14
14
  const require = createRequire(import.meta.url);
15
15
  const packageJson = require("../../package.json") as { version: string };
16
16
 
17
- import { InMemoryEventStore } from "../InMemoryEventStore.js";
18
17
  import { proxyServer } from "../proxyServer.js";
19
18
  import { SSEServer, startHTTPServer } from "../startHTTPServer.js";
20
19
  import { StdioClientTransport } from "../StdioClientTransport.js";
@@ -70,6 +69,18 @@ const argv = await yargs(hideBin(process.argv))
70
69
  describe: "The endpoint to listen on",
71
70
  type: "string",
72
71
  },
72
+ eventStore: {
73
+ default: true,
74
+ describe:
75
+ "Enable the streamable HTTP transport's resumability event store, which lets clients replay missed messages after a reconnect. Use --no-eventStore to disable it entirely for request/response-only deployments that don't need this and would rather avoid the memory overhead",
76
+ type: "boolean",
77
+ },
78
+ eventStoreMaxEvents: {
79
+ default: 1000,
80
+ describe:
81
+ "Maximum number of buffered events the resumability event store retains (per session) before it evicts the oldest; bounds memory use. Ignored when --no-eventStore is set",
82
+ type: "number",
83
+ },
73
84
  gracefulShutdownTimeout: {
74
85
  default: 5000,
75
86
  describe: "The timeout (in milliseconds) for graceful shutdown",
@@ -149,6 +160,13 @@ const argv = await yargs(hideBin(process.argv))
149
160
  .help()
150
161
  .parseAsync();
151
162
 
163
+ if (!(argv.eventStoreMaxEvents >= 1)) {
164
+ console.error(
165
+ `Error: --eventStoreMaxEvents must be a number >= 1 (got ${String(argv.eventStoreMaxEvents)}). Use --no-eventStore to disable the event store instead.`,
166
+ );
167
+ process.exit(1);
168
+ }
169
+
152
170
  // Default Access-Control-Allow-Headers list — must stay in sync with
153
171
  // `defaultCorsOptions.allowedHeaders` in src/startHTTPServer.ts.
154
172
  const DEFAULT_ALLOWED_HEADERS = [
@@ -248,7 +266,8 @@ const proxy = async () => {
248
266
  apiKey: argv.apiKey,
249
267
  cors: corsOption,
250
268
  createServer,
251
- eventStore: new InMemoryEventStore(),
269
+ eventStore: argv.eventStore ? undefined : false,
270
+ eventStoreMaxEvents: argv.eventStoreMaxEvents,
252
271
  host: argv.host,
253
272
  keepAliveTimeout: argv.keepAliveTimeout,
254
273
  port: argv.port,
package/src/index.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export type { AuthConfig } from "./authentication.js";
2
2
  export { AuthenticationMiddleware } from "./authentication.js";
3
+ export type { InMemoryEventStoreOptions } from "./InMemoryEventStore.js";
3
4
  export { InMemoryEventStore } from "./InMemoryEventStore.js";
4
5
  export { proxyServer } from "./proxyServer.js";
5
- export type { CorsOptions } from "./startHTTPServer.js";
6
+ export type { CorsOptions, EventStoreOption } from "./startHTTPServer.js";
6
7
  export { startHTTPServer } from "./startHTTPServer.js";
7
8
  export { ServerType, startStdioServer } from "./startStdioServer.js";
8
9
  export { tapTransport } from "./tapTransport.js";
@@ -2597,3 +2597,78 @@ it("DELETE request to non-existent session returns 400", async () => {
2597
2597
  await httpServer.close();
2598
2598
  await stdioClient.close();
2599
2599
  }, 15000);
2600
+
2601
+ // The SDK only writes a resumability "priming event" (an `id: <eventId>`
2602
+ // SSE line) when an event store is configured, so its presence/absence is a
2603
+ // reliable, wire-level signal of whether resumability is actually active -
2604
+ // see https://github.com/punkpeye/mcp-proxy/issues/72.
2605
+ const initializeAndGetRawBody = async (port: number) => {
2606
+ const response = await fetch(`http://localhost:${port}/mcp`, {
2607
+ body: JSON.stringify({
2608
+ id: 1,
2609
+ jsonrpc: "2.0",
2610
+ method: "initialize",
2611
+ params: {
2612
+ capabilities: {},
2613
+ clientInfo: { name: "test", version: "1.0.0" },
2614
+ // Priming events are only sent to clients whose negotiated protocol
2615
+ // version is >= 2025-11-25.
2616
+ protocolVersion: "2025-11-25",
2617
+ },
2618
+ }),
2619
+ headers: {
2620
+ Accept: "application/json, text/event-stream",
2621
+ "Content-Type": "application/json",
2622
+ },
2623
+ method: "POST",
2624
+ });
2625
+
2626
+ expect(response.status).toBe(200);
2627
+
2628
+ return response.text();
2629
+ };
2630
+
2631
+ it("disables the resumability event store when eventStore: false is passed", async () => {
2632
+ const port = await getRandomPort();
2633
+
2634
+ const httpServer = await startHTTPServer({
2635
+ createServer: async () => {
2636
+ return new Server(
2637
+ { name: "test", version: "1.0.0" },
2638
+ { capabilities: {} },
2639
+ );
2640
+ },
2641
+ eventStore: false,
2642
+ port,
2643
+ });
2644
+
2645
+ try {
2646
+ const body = await initializeAndGetRawBody(port);
2647
+
2648
+ expect(body).not.toMatch(/^id: /m);
2649
+ } finally {
2650
+ await httpServer.close();
2651
+ }
2652
+ });
2653
+
2654
+ it("enables a bounded, per-session resumability event store by default", async () => {
2655
+ const port = await getRandomPort();
2656
+
2657
+ const httpServer = await startHTTPServer({
2658
+ createServer: async () => {
2659
+ return new Server(
2660
+ { name: "test", version: "1.0.0" },
2661
+ { capabilities: {} },
2662
+ );
2663
+ },
2664
+ port,
2665
+ });
2666
+
2667
+ try {
2668
+ const body = await initializeAndGetRawBody(port);
2669
+
2670
+ expect(body).toMatch(/^id: /m);
2671
+ } finally {
2672
+ await httpServer.close();
2673
+ }
2674
+ });
@@ -18,6 +18,25 @@ import { InMemoryEventStore } from "./InMemoryEventStore.js";
18
18
 
19
19
  const DEFAULT_KEEP_ALIVE_TIMEOUT = 300_000;
20
20
 
21
+ /**
22
+ * `false` disables the resumability event store entirely (no replay-on-
23
+ * reconnect, no retained state). Omitted/`undefined` creates a fresh,
24
+ * bounded `InMemoryEventStore` per session - see `eventStoreMaxEvents`.
25
+ * Pass an `EventStore` instance to use a shared or custom-backed store.
26
+ */
27
+ export type EventStoreOption = EventStore | false;
28
+
29
+ const resolveEventStore = (
30
+ eventStore: EventStoreOption | undefined,
31
+ maxEvents: number | undefined,
32
+ ): EventStore | undefined => {
33
+ if (eventStore === false) {
34
+ return undefined;
35
+ }
36
+
37
+ return eventStore ?? new InMemoryEventStore({ maxEvents });
38
+ };
39
+
21
40
  export interface CorsOptions {
22
41
  allowedHeaders?: string | string[]; // Allow string[] or '*' for wildcard
23
42
  credentials?: boolean;
@@ -381,6 +400,7 @@ const handleStreamRequest = async <T extends ServerLike>({
381
400
  enableJsonResponse,
382
401
  endpoint,
383
402
  eventStore,
403
+ eventStoreMaxEvents,
384
404
  oauth,
385
405
  onClose,
386
406
  onConnect,
@@ -397,7 +417,8 @@ const handleStreamRequest = async <T extends ServerLike>({
397
417
  createServer: (request: http.IncomingMessage) => Promise<T>;
398
418
  enableJsonResponse?: boolean;
399
419
  endpoint: string;
400
- eventStore?: EventStore;
420
+ eventStore?: EventStoreOption;
421
+ eventStoreMaxEvents?: number;
401
422
  oauth?: AuthConfig["oauth"];
402
423
  onClose?: (server: T) => Promise<void>;
403
424
  onConnect?: (server: T) => Promise<void>;
@@ -543,7 +564,7 @@ const handleStreamRequest = async <T extends ServerLike>({
543
564
  // Create a new transport for the session
544
565
  transport = new StreamableHTTPServerTransport({
545
566
  enableJsonResponse,
546
- eventStore: eventStore || new InMemoryEventStore(),
567
+ eventStore: resolveEventStore(eventStore, eventStoreMaxEvents),
547
568
  onsessioninitialized: (_sessionId) => {
548
569
  // add only when the id Session id is generated (skip in stateless mode)
549
570
  if (!stateless && _sessionId) {
@@ -637,7 +658,7 @@ const handleStreamRequest = async <T extends ServerLike>({
637
658
  // In stateless mode, handle non-initialize requests by creating a new transport
638
659
  transport = new StreamableHTTPServerTransport({
639
660
  enableJsonResponse,
640
- eventStore: eventStore || new InMemoryEventStore(),
661
+ eventStore: resolveEventStore(eventStore, eventStoreMaxEvents),
641
662
  onsessioninitialized: () => {
642
663
  // No session tracking in stateless mode
643
664
  },
@@ -979,6 +1000,7 @@ export const startHTTPServer = async <T extends ServerLike>({
979
1000
  createServer,
980
1001
  enableJsonResponse,
981
1002
  eventStore,
1003
+ eventStoreMaxEvents,
982
1004
  host = "::",
983
1005
  keepAliveTimeout = DEFAULT_KEEP_ALIVE_TIMEOUT,
984
1006
  oauth,
@@ -998,7 +1020,22 @@ export const startHTTPServer = async <T extends ServerLike>({
998
1020
  cors?: boolean | CorsOptions;
999
1021
  createServer: (request: http.IncomingMessage) => Promise<T>;
1000
1022
  enableJsonResponse?: boolean;
1001
- eventStore?: EventStore;
1023
+ /**
1024
+ * Event store for the streamable HTTP transport's resumability support.
1025
+ * Pass `false` to disable resumability entirely (recommended for
1026
+ * request/response-only deployments that don't need replay-on-reconnect).
1027
+ * Omit to get a fresh, bounded `InMemoryEventStore` per session - see
1028
+ * `eventStoreMaxEvents`. Pass an `EventStore` instance to bring your own
1029
+ * (e.g. a persistent, cross-process store); it will be shared across all
1030
+ * sessions handled by this server.
1031
+ */
1032
+ eventStore?: EventStoreOption;
1033
+ /**
1034
+ * Caps how many events the auto-created per-session `InMemoryEventStore`
1035
+ * retains (oldest evicted first) before it's overridden by an explicit
1036
+ * `eventStore`. Bounds memory for long-lived sessions. Default: 1000.
1037
+ */
1038
+ eventStoreMaxEvents?: number;
1002
1039
  host?: string;
1003
1040
  keepAliveTimeout?: number;
1004
1041
  oauth?: AuthConfig["oauth"];
@@ -1107,6 +1144,7 @@ export const startHTTPServer = async <T extends ServerLike>({
1107
1144
  enableJsonResponse,
1108
1145
  endpoint: streamEndpoint,
1109
1146
  eventStore,
1147
+ eventStoreMaxEvents,
1110
1148
  oauth,
1111
1149
  onClose,
1112
1150
  onConnect,