mcp-proxy 6.5.3 → 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.3"
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.3",
3
+ "version": "6.5.4",
4
4
  "main": "dist/index.mjs",
5
5
  "scripts": {
6
6
  "build": "tsdown",
@@ -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"],
@@ -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";
@@ -62,6 +65,35 @@ const createJsonRpcErrorResponse = (code: number, message: string) => {
62
65
  });
63
66
  };
64
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
+
65
97
  // Helper function to get WWW-Authenticate header value
66
98
  const getWWWAuthenticateHeader = (
67
99
  oauth?: AuthConfig["oauth"],
@@ -125,6 +157,35 @@ const getWWWAuthenticateHeader = (
125
157
  return `Bearer ${params.join(", ")}`;
126
158
  };
127
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
+
128
189
  // Helper function to detect scope challenge errors
129
190
  const isScopeChallengeError = (
130
191
  error: unknown,
@@ -353,9 +414,9 @@ const handleStreamRequest = async <T extends ServerLike>({
353
414
  // In stateless mode, ignore session ID header entirely (like Python MCP SDK)
354
415
  const sessionId = stateless
355
416
  ? undefined
356
- : (Array.isArray(req.headers["mcp-session-id"])
357
- ? req.headers["mcp-session-id"][0]
358
- : 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"];
359
420
 
360
421
  let transport: StreamableHTTPServerTransport;
361
422
 
@@ -449,6 +510,12 @@ const handleStreamRequest = async <T extends ServerLike>({
449
510
  if (sessionId) {
450
511
  const activeTransport = activeTransports[sessionId];
451
512
  if (!activeTransport) {
513
+ if (authenticate && isJsonRpcBody(body)) {
514
+ sendSessionUnauthorizedResponse({ body, oauth, res });
515
+
516
+ return true;
517
+ }
518
+
452
519
  res.setHeader("Content-Type", "application/json");
453
520
  res
454
521
  .writeHead(404)
@@ -634,6 +701,12 @@ const handleStreamRequest = async <T extends ServerLike>({
634
701
 
635
702
  return true;
636
703
  } else {
704
+ if (authenticate && isJsonRpcBody(body)) {
705
+ sendSessionUnauthorizedResponse({ body, oauth, res });
706
+
707
+ return true;
708
+ }
709
+
637
710
  // Error if the server is not created but the request is not an initialize request
638
711
  res.setHeader("Content-Type", "application/json");
639
712
 
@@ -690,7 +763,7 @@ const handleStreamRequest = async <T extends ServerLike>({
690
763
  }
691
764
  | undefined = sessionId ? activeTransports[sessionId] : undefined;
692
765
 
693
- if (!sessionId) {
766
+ if (!sessionId) {
694
767
  // Return METHOD_NOT_ALLOWED so stateless clients' transport stops reconnecting
695
768
  if (stateless) {
696
769
  res.writeHead(405, { Allow: "POST" }).end("Method Not Allowed");
@@ -698,12 +771,24 @@ const handleStreamRequest = async <T extends ServerLike>({
698
771
  return true;
699
772
  }
700
773
 
774
+ if (authenticate) {
775
+ sendSessionUnauthorizedResponse({ oauth, res });
776
+
777
+ return true;
778
+ }
779
+
701
780
  res.writeHead(400).end("No sessionId");
702
781
 
703
782
  return true;
704
783
  }
705
784
 
706
785
  if (!activeTransport) {
786
+ if (authenticate) {
787
+ sendSessionUnauthorizedResponse({ oauth, res });
788
+
789
+ return true;
790
+ }
791
+
707
792
  res.writeHead(400).end("No active transport");
708
793
 
709
794
  return true;
@@ -735,6 +820,12 @@ const handleStreamRequest = async <T extends ServerLike>({
735
820
  const sessionId = req.headers["mcp-session-id"] as string | undefined;
736
821
 
737
822
  if (!sessionId) {
823
+ if (authenticate) {
824
+ sendSessionUnauthorizedResponse({ oauth, res });
825
+
826
+ return true;
827
+ }
828
+
738
829
  res.writeHead(400).end("Invalid or missing sessionId");
739
830
 
740
831
  return true;
@@ -745,6 +836,12 @@ const handleStreamRequest = async <T extends ServerLike>({
745
836
  const activeTransport = activeTransports[sessionId];
746
837
 
747
838
  if (!activeTransport) {
839
+ if (authenticate) {
840
+ sendSessionUnauthorizedResponse({ oauth, res });
841
+
842
+ return true;
843
+ }
844
+
748
845
  res.writeHead(400).end("No active transport");
749
846
  return true;
750
847
  }
@@ -1024,7 +1121,7 @@ export const startHTTPServer = async <T extends ServerLike>({
1024
1121
  res.writeHead(404).end();
1025
1122
  };
1026
1123
 
1027
- let httpServer;
1124
+ let httpServer: http.Server | https.Server;
1028
1125
  if (sslCa || sslCert || sslKey) {
1029
1126
  const options: https.ServerOptions = {};
1030
1127
  if (sslCa) {