mcp-proxy 6.5.2 → 6.5.4

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.2"
6
+ "version": "6.5.4"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-proxy",
3
- "version": "6.5.2",
3
+ "version": "6.5.4",
4
4
  "main": "dist/index.mjs",
5
5
  "scripts": {
6
6
  "build": "tsdown",
@@ -80,6 +80,12 @@ const argv = await yargs(hideBin(process.argv))
80
80
  describe: "The host to listen on",
81
81
  type: "string",
82
82
  },
83
+ keepAliveTimeout: {
84
+ default: 300000,
85
+ describe:
86
+ "The HTTP keep-alive timeout in milliseconds for stateful stream sessions (default: 5 minutes)",
87
+ type: "number",
88
+ },
83
89
  port: {
84
90
  default: 8080,
85
91
  describe: "The port to listen on",
@@ -244,6 +250,7 @@ const proxy = async () => {
244
250
  createServer,
245
251
  eventStore: new InMemoryEventStore(),
246
252
  host: argv.host,
253
+ keepAliveTimeout: argv.keepAliveTimeout,
247
254
  port: argv.port,
248
255
  sseEndpoint:
249
256
  argv.server && argv.server !== "sse"
@@ -131,6 +131,73 @@ it("proxies messages between HTTP stream and stdio servers", async () => {
131
131
  expect(onClose).toHaveBeenCalled();
132
132
  });
133
133
 
134
+ it(
135
+ "keeps stateful HTTP stream sessions alive after idle keep-alive timeout window",
136
+ async () => {
137
+ const port = await getRandomPort();
138
+ const onClose = vi.fn().mockResolvedValue(undefined);
139
+
140
+ const httpServer = await startHTTPServer({
141
+ createServer: async () => {
142
+ return new Server(
143
+ { name: "test", version: "1.0.0" },
144
+ { capabilities: {} },
145
+ );
146
+ },
147
+ onClose,
148
+ port,
149
+ });
150
+
151
+ const initializeResponse = await fetch(`http://localhost:${port}/mcp`, {
152
+ body: JSON.stringify({
153
+ id: 1,
154
+ jsonrpc: "2.0",
155
+ method: "initialize",
156
+ params: {
157
+ capabilities: {},
158
+ clientInfo: { name: "test", version: "1.0.0" },
159
+ protocolVersion: "2025-03-26",
160
+ },
161
+ }),
162
+ headers: {
163
+ Accept: "application/json, text/event-stream",
164
+ "Content-Type": "application/json",
165
+ },
166
+ method: "POST",
167
+ });
168
+
169
+ expect(initializeResponse.status).toBe(200);
170
+ const sessionId = initializeResponse.headers.get("mcp-session-id");
171
+ expect(sessionId).toBeTruthy();
172
+ await initializeResponse.text();
173
+
174
+ await delay(6_000);
175
+
176
+ const listToolsResponse = await fetch(`http://localhost:${port}/mcp`, {
177
+ body: JSON.stringify({
178
+ id: 2,
179
+ jsonrpc: "2.0",
180
+ method: "tools/list",
181
+ params: {},
182
+ }),
183
+ headers: {
184
+ Accept: "application/json, text/event-stream",
185
+ "Content-Type": "application/json",
186
+ "mcp-session-id": sessionId!,
187
+ },
188
+ method: "POST",
189
+ });
190
+ const listToolsBody = await listToolsResponse.text();
191
+
192
+ expect(listToolsResponse.status).not.toBe(404);
193
+ expect(listToolsBody).not.toContain("Session not found");
194
+ expect(onClose).not.toHaveBeenCalled();
195
+
196
+ await httpServer.close();
197
+ },
198
+ 15_000,
199
+ );
200
+
134
201
  it("proxies messages between SSE and stdio servers", async () => {
135
202
  const stdioTransport = new StdioClientTransport({
136
203
  args: ["src/fixtures/simple-stdio-server.ts"],
@@ -926,6 +993,136 @@ it("accepts requests with valid Bearer token in stateless mode", async () => {
926
993
  await stdioClient.close();
927
994
  });
928
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
+
929
1126
  it("returns 401 when authenticate callback returns null in stateless mode", async () => {
930
1127
  const stdioTransport = new StdioClientTransport({
931
1128
  args: ["src/fixtures/simple-stdio-server.ts"],
@@ -4,7 +4,10 @@ import {
4
4
  EventStore,
5
5
  StreamableHTTPServerTransport,
6
6
  } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
- import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
7
+ import {
8
+ isInitializeRequest,
9
+ JSONRPCMessageSchema,
10
+ } from "@modelcontextprotocol/sdk/types.js";
8
11
  import fs from "fs";
9
12
  import http from "http";
10
13
  import https from "https";
@@ -13,6 +16,8 @@ import { randomUUID } from "node:crypto";
13
16
  import { AuthConfig, AuthenticationMiddleware } from "./authentication.js";
14
17
  import { InMemoryEventStore } from "./InMemoryEventStore.js";
15
18
 
19
+ const DEFAULT_KEEP_ALIVE_TIMEOUT = 300_000;
20
+
16
21
  export interface CorsOptions {
17
22
  allowedHeaders?: string | string[]; // Allow string[] or '*' for wildcard
18
23
  credentials?: boolean;
@@ -60,6 +65,35 @@ const createJsonRpcErrorResponse = (code: number, message: string) => {
60
65
  });
61
66
  };
62
67
 
68
+ type SessionUnauthorizedResponseOptions = {
69
+ readonly body?: unknown;
70
+ readonly oauth?: AuthConfig["oauth"];
71
+ readonly res: http.ServerResponse;
72
+ };
73
+
74
+ const getRequestId = (body: unknown): unknown => {
75
+ if (
76
+ typeof body !== "object" ||
77
+ body === null ||
78
+ Array.isArray(body) ||
79
+ !("id" in body)
80
+ ) {
81
+ return null;
82
+ }
83
+
84
+ return body.id;
85
+ };
86
+
87
+ const isJsonRpcMessage = (message: unknown): boolean => {
88
+ return JSONRPCMessageSchema.safeParse(message).success;
89
+ };
90
+
91
+ const isJsonRpcBody = (body: unknown): boolean => {
92
+ return Array.isArray(body)
93
+ ? body.every(isJsonRpcMessage)
94
+ : isJsonRpcMessage(body);
95
+ };
96
+
63
97
  // Helper function to get WWW-Authenticate header value
64
98
  const getWWWAuthenticateHeader = (
65
99
  oauth?: AuthConfig["oauth"],
@@ -123,6 +157,35 @@ const getWWWAuthenticateHeader = (
123
157
  return `Bearer ${params.join(", ")}`;
124
158
  };
125
159
 
160
+ const sendSessionUnauthorizedResponse = ({
161
+ body,
162
+ oauth,
163
+ res,
164
+ }: SessionUnauthorizedResponseOptions): void => {
165
+ const message = "Unauthorized: No valid session ID provided";
166
+
167
+ res.setHeader("Content-Type", "application/json");
168
+
169
+ const wwwAuthHeader = getWWWAuthenticateHeader(oauth, {
170
+ error: "invalid_token",
171
+ error_description: message,
172
+ });
173
+ if (wwwAuthHeader) {
174
+ res.setHeader("WWW-Authenticate", wwwAuthHeader);
175
+ }
176
+
177
+ res.writeHead(401).end(
178
+ JSON.stringify({
179
+ error: {
180
+ code: -32000,
181
+ message,
182
+ },
183
+ id: getRequestId(body),
184
+ jsonrpc: "2.0",
185
+ }),
186
+ );
187
+ };
188
+
126
189
  // Helper function to detect scope challenge errors
127
190
  const isScopeChallengeError = (
128
191
  error: unknown,
@@ -351,9 +414,9 @@ const handleStreamRequest = async <T extends ServerLike>({
351
414
  // In stateless mode, ignore session ID header entirely (like Python MCP SDK)
352
415
  const sessionId = stateless
353
416
  ? undefined
354
- : (Array.isArray(req.headers["mcp-session-id"])
355
- ? req.headers["mcp-session-id"][0]
356
- : req.headers["mcp-session-id"]);
417
+ : Array.isArray(req.headers["mcp-session-id"])
418
+ ? req.headers["mcp-session-id"][0]
419
+ : req.headers["mcp-session-id"];
357
420
 
358
421
  let transport: StreamableHTTPServerTransport;
359
422
 
@@ -447,6 +510,12 @@ const handleStreamRequest = async <T extends ServerLike>({
447
510
  if (sessionId) {
448
511
  const activeTransport = activeTransports[sessionId];
449
512
  if (!activeTransport) {
513
+ if (authenticate && isJsonRpcBody(body)) {
514
+ sendSessionUnauthorizedResponse({ body, oauth, res });
515
+
516
+ return true;
517
+ }
518
+
450
519
  res.setHeader("Content-Type", "application/json");
451
520
  res
452
521
  .writeHead(404)
@@ -632,6 +701,12 @@ const handleStreamRequest = async <T extends ServerLike>({
632
701
 
633
702
  return true;
634
703
  } else {
704
+ if (authenticate && isJsonRpcBody(body)) {
705
+ sendSessionUnauthorizedResponse({ body, oauth, res });
706
+
707
+ return true;
708
+ }
709
+
635
710
  // Error if the server is not created but the request is not an initialize request
636
711
  res.setHeader("Content-Type", "application/json");
637
712
 
@@ -688,7 +763,7 @@ const handleStreamRequest = async <T extends ServerLike>({
688
763
  }
689
764
  | undefined = sessionId ? activeTransports[sessionId] : undefined;
690
765
 
691
- if (!sessionId) {
766
+ if (!sessionId) {
692
767
  // Return METHOD_NOT_ALLOWED so stateless clients' transport stops reconnecting
693
768
  if (stateless) {
694
769
  res.writeHead(405, { Allow: "POST" }).end("Method Not Allowed");
@@ -696,12 +771,24 @@ const handleStreamRequest = async <T extends ServerLike>({
696
771
  return true;
697
772
  }
698
773
 
774
+ if (authenticate) {
775
+ sendSessionUnauthorizedResponse({ oauth, res });
776
+
777
+ return true;
778
+ }
779
+
699
780
  res.writeHead(400).end("No sessionId");
700
781
 
701
782
  return true;
702
783
  }
703
784
 
704
785
  if (!activeTransport) {
786
+ if (authenticate) {
787
+ sendSessionUnauthorizedResponse({ oauth, res });
788
+
789
+ return true;
790
+ }
791
+
705
792
  res.writeHead(400).end("No active transport");
706
793
 
707
794
  return true;
@@ -733,6 +820,12 @@ const handleStreamRequest = async <T extends ServerLike>({
733
820
  const sessionId = req.headers["mcp-session-id"] as string | undefined;
734
821
 
735
822
  if (!sessionId) {
823
+ if (authenticate) {
824
+ sendSessionUnauthorizedResponse({ oauth, res });
825
+
826
+ return true;
827
+ }
828
+
736
829
  res.writeHead(400).end("Invalid or missing sessionId");
737
830
 
738
831
  return true;
@@ -743,6 +836,12 @@ const handleStreamRequest = async <T extends ServerLike>({
743
836
  const activeTransport = activeTransports[sessionId];
744
837
 
745
838
  if (!activeTransport) {
839
+ if (authenticate) {
840
+ sendSessionUnauthorizedResponse({ oauth, res });
841
+
842
+ return true;
843
+ }
844
+
746
845
  res.writeHead(400).end("No active transport");
747
846
  return true;
748
847
  }
@@ -881,6 +980,7 @@ export const startHTTPServer = async <T extends ServerLike>({
881
980
  enableJsonResponse,
882
981
  eventStore,
883
982
  host = "::",
983
+ keepAliveTimeout = DEFAULT_KEEP_ALIVE_TIMEOUT,
884
984
  oauth,
885
985
  onClose,
886
986
  onConnect,
@@ -900,6 +1000,7 @@ export const startHTTPServer = async <T extends ServerLike>({
900
1000
  enableJsonResponse?: boolean;
901
1001
  eventStore?: EventStore;
902
1002
  host?: string;
1003
+ keepAliveTimeout?: number;
903
1004
  oauth?: AuthConfig["oauth"];
904
1005
  onClose?: (server: T) => Promise<void>;
905
1006
  onConnect?: (server: T) => Promise<void>;
@@ -1020,7 +1121,7 @@ export const startHTTPServer = async <T extends ServerLike>({
1020
1121
  res.writeHead(404).end();
1021
1122
  };
1022
1123
 
1023
- let httpServer;
1124
+ let httpServer: http.Server | https.Server;
1024
1125
  if (sslCa || sslCert || sslKey) {
1025
1126
  const options: https.ServerOptions = {};
1026
1127
  if (sslCa) {
@@ -1055,6 +1156,14 @@ export const startHTTPServer = async <T extends ServerLike>({
1055
1156
  httpServer = http.createServer(requestListener);
1056
1157
  }
1057
1158
 
1159
+ // Keep stateful stream sessions from being torn down when Node closes
1160
+ // otherwise-idle HTTP keep-alive sockets after its 5 second default.
1161
+ httpServer.keepAliveTimeout = keepAliveTimeout;
1162
+ httpServer.headersTimeout = Math.max(
1163
+ httpServer.headersTimeout,
1164
+ keepAliveTimeout + 1000,
1165
+ );
1166
+
1058
1167
  await new Promise((resolve) => {
1059
1168
  httpServer.listen(port, host, () => {
1060
1169
  resolve(undefined);