mcp-proxy 6.5.3 → 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.3"
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.3",
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";
@@ -993,6 +993,136 @@ it("accepts requests with valid Bearer token in stateless mode", async () => {
993
993
  await stdioClient.close();
994
994
  });
995
995
 
996
+ it("returns 401 for authenticated stream requests without a session ID", async () => {
997
+ const port = await getRandomPort();
998
+ const authenticate = vi.fn().mockResolvedValue({ userId: "test-user" });
999
+ const createServer = vi.fn(async () => {
1000
+ return new Server({ name: "test", version: "1.0.0" }, { capabilities: {} });
1001
+ });
1002
+
1003
+ const httpServer = await startHTTPServer({
1004
+ authenticate,
1005
+ createServer,
1006
+ port,
1007
+ });
1008
+
1009
+ try {
1010
+ const response = await fetch(`http://localhost:${port}/mcp`, {
1011
+ body: JSON.stringify({
1012
+ id: 1,
1013
+ jsonrpc: "2.0",
1014
+ method: "tools/list",
1015
+ }),
1016
+ headers: {
1017
+ Accept: "application/json, text/event-stream",
1018
+ Authorization: "Bearer valid-token",
1019
+ "Content-Type": "application/json",
1020
+ },
1021
+ method: "POST",
1022
+ });
1023
+
1024
+ expect(response.status).toBe(401);
1025
+
1026
+ const errorResponse = (await response.json()) as {
1027
+ error: { code: number; message: string };
1028
+ id: null | number;
1029
+ jsonrpc: string;
1030
+ };
1031
+ expect(errorResponse.error).toEqual({
1032
+ code: -32000,
1033
+ message: "Unauthorized: No valid session ID provided",
1034
+ });
1035
+ expect(errorResponse.id).toBe(1);
1036
+ expect(authenticate).toHaveBeenCalledTimes(1);
1037
+ expect(createServer).not.toHaveBeenCalled();
1038
+ } finally {
1039
+ await httpServer.close();
1040
+ }
1041
+ });
1042
+
1043
+ it("returns 401 for authenticated stream GET requests without a session ID", async () => {
1044
+ const port = await getRandomPort();
1045
+ const authenticate = vi.fn().mockResolvedValue({ userId: "test-user" });
1046
+
1047
+ const httpServer = await startHTTPServer({
1048
+ authenticate,
1049
+ createServer: async () => {
1050
+ return new Server(
1051
+ { name: "test", version: "1.0.0" },
1052
+ { capabilities: {} },
1053
+ );
1054
+ },
1055
+ port,
1056
+ });
1057
+
1058
+ try {
1059
+ const response = await fetch(`http://localhost:${port}/mcp`, {
1060
+ headers: {
1061
+ Accept: "text/event-stream",
1062
+ Authorization: "Bearer valid-token",
1063
+ },
1064
+ method: "GET",
1065
+ });
1066
+
1067
+ expect(response.status).toBe(401);
1068
+
1069
+ const errorResponse = (await response.json()) as {
1070
+ error: { code: number; message: string };
1071
+ id: null | number;
1072
+ jsonrpc: string;
1073
+ };
1074
+ expect(errorResponse.error.message).toBe(
1075
+ "Unauthorized: No valid session ID provided",
1076
+ );
1077
+ expect(errorResponse.id).toBeNull();
1078
+ expect(authenticate).not.toHaveBeenCalled();
1079
+ } finally {
1080
+ await httpServer.close();
1081
+ }
1082
+ });
1083
+
1084
+ it("keeps malformed authenticated stream requests as 400", async () => {
1085
+ const port = await getRandomPort();
1086
+ const authenticate = vi.fn().mockResolvedValue({ userId: "test-user" });
1087
+
1088
+ const httpServer = await startHTTPServer({
1089
+ authenticate,
1090
+ createServer: async () => {
1091
+ return new Server(
1092
+ { name: "test", version: "1.0.0" },
1093
+ { capabilities: {} },
1094
+ );
1095
+ },
1096
+ port,
1097
+ });
1098
+
1099
+ try {
1100
+ const response = await fetch(`http://localhost:${port}/mcp`, {
1101
+ body: JSON.stringify({ malformed: true }),
1102
+ headers: {
1103
+ Accept: "application/json, text/event-stream",
1104
+ Authorization: "Bearer valid-token",
1105
+ "Content-Type": "application/json",
1106
+ },
1107
+ method: "POST",
1108
+ });
1109
+
1110
+ expect(response.status).toBe(400);
1111
+
1112
+ const errorResponse = (await response.json()) as {
1113
+ error: { code: number; message: string };
1114
+ id: null | number;
1115
+ jsonrpc: string;
1116
+ };
1117
+ expect(errorResponse.error.message).toBe(
1118
+ "Bad Request: No valid session ID provided",
1119
+ );
1120
+ expect(authenticate).toHaveBeenCalledTimes(1);
1121
+ } finally {
1122
+ await httpServer.close();
1123
+ }
1124
+ });
1125
+
996
1126
  it("returns 401 when authenticate callback returns null in stateless mode", async () => {
997
1127
  const stdioTransport = new StdioClientTransport({
998
1128
  args: ["src/fixtures/simple-stdio-server.ts"],
@@ -2467,3 +2597,78 @@ it("DELETE request to non-existent session returns 400", async () => {
2467
2597
  await httpServer.close();
2468
2598
  await stdioClient.close();
2469
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
+ });