mcp-proxy 5.6.1 → 5.8.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.
@@ -701,3 +701,483 @@ it("does not require auth for OPTIONS requests", async () => {
701
701
 
702
702
  await httpServer.close();
703
703
  });
704
+
705
+ // Stateless OAuth 2.0 JWT Bearer Token Authentication Tests (PR #37)
706
+
707
+ it("accepts requests with valid Bearer token in stateless mode", async () => {
708
+ const stdioTransport = new StdioClientTransport({
709
+ args: ["src/fixtures/simple-stdio-server.ts"],
710
+ command: "tsx",
711
+ });
712
+
713
+ const stdioClient = new Client(
714
+ {
715
+ name: "mcp-proxy",
716
+ version: "1.0.0",
717
+ },
718
+ {
719
+ capabilities: {},
720
+ },
721
+ );
722
+
723
+ await stdioClient.connect(stdioTransport);
724
+
725
+ const serverVersion = stdioClient.getServerVersion() as {
726
+ name: string;
727
+ version: string;
728
+ };
729
+
730
+ const serverCapabilities = stdioClient.getServerCapabilities() as {
731
+ capabilities: Record<string, unknown>;
732
+ };
733
+
734
+ const port = await getRandomPort();
735
+
736
+ // Mock authenticate callback that validates JWT Bearer token
737
+ const mockAuthResult = { email: "test@example.com", userId: "user123" };
738
+ const authenticate = vi.fn().mockResolvedValue(mockAuthResult);
739
+
740
+ const httpServer = await startHTTPServer({
741
+ authenticate,
742
+ createServer: async () => {
743
+ const mcpServer = new Server(serverVersion, {
744
+ capabilities: serverCapabilities,
745
+ });
746
+
747
+ await proxyServer({
748
+ client: stdioClient,
749
+ server: mcpServer,
750
+ serverCapabilities,
751
+ });
752
+
753
+ return mcpServer;
754
+ },
755
+ port,
756
+ stateless: true, // Enable stateless mode
757
+ });
758
+
759
+ // Create a stateless streamable HTTP client with Bearer token
760
+ const streamTransport = new StreamableHTTPClientTransport(
761
+ new URL(`http://localhost:${port}/mcp`),
762
+ {
763
+ requestInit: {
764
+ headers: {
765
+ Authorization: "Bearer valid-jwt-token",
766
+ },
767
+ },
768
+ },
769
+ );
770
+
771
+ const streamClient = new Client(
772
+ {
773
+ name: "stream-client-oauth",
774
+ version: "1.0.0",
775
+ },
776
+ {
777
+ capabilities: {},
778
+ },
779
+ );
780
+
781
+ await streamClient.connect(streamTransport);
782
+
783
+ // Test that we can make requests with valid authentication
784
+ const result = await streamClient.listResources();
785
+ expect(result).toEqual({
786
+ resources: [
787
+ {
788
+ name: "Example Resource",
789
+ uri: "file:///example.txt",
790
+ },
791
+ ],
792
+ });
793
+
794
+ // Verify authenticate callback was called
795
+ expect(authenticate).toHaveBeenCalled();
796
+
797
+ await streamClient.close();
798
+ await httpServer.close();
799
+ await stdioClient.close();
800
+ });
801
+
802
+ it("returns 401 when authenticate callback returns null in stateless mode", async () => {
803
+ const stdioTransport = new StdioClientTransport({
804
+ args: ["src/fixtures/simple-stdio-server.ts"],
805
+ command: "tsx",
806
+ });
807
+
808
+ const stdioClient = new Client(
809
+ {
810
+ name: "mcp-proxy",
811
+ version: "1.0.0",
812
+ },
813
+ {
814
+ capabilities: {},
815
+ },
816
+ );
817
+
818
+ await stdioClient.connect(stdioTransport);
819
+
820
+ const serverVersion = stdioClient.getServerVersion() as {
821
+ name: string;
822
+ version: string;
823
+ };
824
+
825
+ const serverCapabilities = stdioClient.getServerCapabilities() as {
826
+ capabilities: Record<string, unknown>;
827
+ };
828
+
829
+ const port = await getRandomPort();
830
+
831
+ // Mock authenticate callback that rejects invalid token
832
+ const authenticate = vi.fn().mockResolvedValue(null);
833
+
834
+ const httpServer = await startHTTPServer({
835
+ authenticate,
836
+ createServer: async () => {
837
+ const mcpServer = new Server(serverVersion, {
838
+ capabilities: serverCapabilities,
839
+ });
840
+
841
+ await proxyServer({
842
+ client: stdioClient,
843
+ server: mcpServer,
844
+ serverCapabilities,
845
+ });
846
+
847
+ return mcpServer;
848
+ },
849
+ port,
850
+ stateless: true,
851
+ });
852
+
853
+ // Create client with invalid Bearer token
854
+ const streamTransport = new StreamableHTTPClientTransport(
855
+ new URL(`http://localhost:${port}/mcp`),
856
+ {
857
+ requestInit: {
858
+ headers: {
859
+ Authorization: "Bearer invalid-jwt-token",
860
+ },
861
+ },
862
+ },
863
+ );
864
+
865
+ const streamClient = new Client(
866
+ {
867
+ name: "stream-client-invalid-token",
868
+ version: "1.0.0",
869
+ },
870
+ {
871
+ capabilities: {},
872
+ },
873
+ );
874
+
875
+ // Connection should fail due to invalid authentication
876
+ await expect(streamClient.connect(streamTransport)).rejects.toThrow();
877
+
878
+ // Verify authenticate callback was called
879
+ expect(authenticate).toHaveBeenCalled();
880
+
881
+ await httpServer.close();
882
+ await stdioClient.close();
883
+ });
884
+
885
+ it("returns 401 when authenticate callback throws error in stateless mode", async () => {
886
+ const stdioTransport = new StdioClientTransport({
887
+ args: ["src/fixtures/simple-stdio-server.ts"],
888
+ command: "tsx",
889
+ });
890
+
891
+ const stdioClient = new Client(
892
+ {
893
+ name: "mcp-proxy",
894
+ version: "1.0.0",
895
+ },
896
+ {
897
+ capabilities: {},
898
+ },
899
+ );
900
+
901
+ await stdioClient.connect(stdioTransport);
902
+
903
+ const serverVersion = stdioClient.getServerVersion() as {
904
+ name: string;
905
+ version: string;
906
+ };
907
+
908
+ const serverCapabilities = stdioClient.getServerCapabilities() as {
909
+ capabilities: Record<string, unknown>;
910
+ };
911
+
912
+ const port = await getRandomPort();
913
+
914
+ // Mock authenticate callback that throws (e.g., JWKS endpoint failure)
915
+ const authenticate = vi
916
+ .fn()
917
+ .mockRejectedValue(new Error("JWKS fetch failed"));
918
+
919
+ const httpServer = await startHTTPServer({
920
+ authenticate,
921
+ createServer: async () => {
922
+ const mcpServer = new Server(serverVersion, {
923
+ capabilities: serverCapabilities,
924
+ });
925
+
926
+ await proxyServer({
927
+ client: stdioClient,
928
+ server: mcpServer,
929
+ serverCapabilities,
930
+ });
931
+
932
+ return mcpServer;
933
+ },
934
+ port,
935
+ stateless: true,
936
+ });
937
+
938
+ // Create client with Bearer token
939
+ const streamTransport = new StreamableHTTPClientTransport(
940
+ new URL(`http://localhost:${port}/mcp`),
941
+ {
942
+ requestInit: {
943
+ headers: {
944
+ Authorization: "Bearer some-token",
945
+ },
946
+ },
947
+ },
948
+ );
949
+
950
+ const streamClient = new Client(
951
+ {
952
+ name: "stream-client-auth-error",
953
+ version: "1.0.0",
954
+ },
955
+ {
956
+ capabilities: {},
957
+ },
958
+ );
959
+
960
+ // Connection should fail due to authentication error
961
+ await expect(streamClient.connect(streamTransport)).rejects.toThrow();
962
+
963
+ // Verify authenticate callback was called
964
+ expect(authenticate).toHaveBeenCalled();
965
+
966
+ await httpServer.close();
967
+ await stdioClient.close();
968
+ });
969
+
970
+ it("does not call authenticate on subsequent requests in stateful mode", async () => {
971
+ const stdioTransport = new StdioClientTransport({
972
+ args: ["src/fixtures/simple-stdio-server.ts"],
973
+ command: "tsx",
974
+ });
975
+
976
+ const stdioClient = new Client(
977
+ {
978
+ name: "mcp-proxy",
979
+ version: "1.0.0",
980
+ },
981
+ {
982
+ capabilities: {},
983
+ },
984
+ );
985
+
986
+ await stdioClient.connect(stdioTransport);
987
+
988
+ const serverVersion = stdioClient.getServerVersion() as {
989
+ name: string;
990
+ version: string;
991
+ };
992
+
993
+ const serverCapabilities = stdioClient.getServerCapabilities() as {
994
+ capabilities: Record<string, unknown>;
995
+ };
996
+
997
+ const port = await getRandomPort();
998
+
999
+ // Mock authenticate callback
1000
+ const authenticate = vi.fn().mockResolvedValue({ userId: "user123" });
1001
+
1002
+ const onConnect = vi.fn().mockResolvedValue(undefined);
1003
+ const onClose = vi.fn().mockResolvedValue(undefined);
1004
+
1005
+ const httpServer = await startHTTPServer({
1006
+ authenticate,
1007
+ createServer: async () => {
1008
+ const mcpServer = new Server(serverVersion, {
1009
+ capabilities: serverCapabilities,
1010
+ });
1011
+
1012
+ await proxyServer({
1013
+ client: stdioClient,
1014
+ server: mcpServer,
1015
+ serverCapabilities,
1016
+ });
1017
+
1018
+ return mcpServer;
1019
+ },
1020
+ onClose,
1021
+ onConnect,
1022
+ port,
1023
+ stateless: false, // Explicitly use stateful mode
1024
+ });
1025
+
1026
+ // Create client
1027
+ const streamTransport = new StreamableHTTPClientTransport(
1028
+ new URL(`http://localhost:${port}/mcp`),
1029
+ );
1030
+
1031
+ const streamClient = new Client(
1032
+ {
1033
+ name: "stream-client-stateful",
1034
+ version: "1.0.0",
1035
+ },
1036
+ {
1037
+ capabilities: {},
1038
+ },
1039
+ );
1040
+
1041
+ await streamClient.connect(streamTransport);
1042
+
1043
+ // Make first request
1044
+ await streamClient.listResources();
1045
+
1046
+ // Make second request
1047
+ await streamClient.listResources();
1048
+
1049
+ // In stateful mode, authenticate should NOT be called per-request
1050
+ // It may be called during initialization, but not on every tool call
1051
+ // The key is that it's not called multiple times for each request
1052
+ expect(authenticate).not.toHaveBeenCalled();
1053
+
1054
+ await streamClient.close();
1055
+ await httpServer.close();
1056
+ await stdioClient.close();
1057
+ });
1058
+
1059
+ it("calls authenticate on every request in stateless mode", async () => {
1060
+ const stdioTransport = new StdioClientTransport({
1061
+ args: ["src/fixtures/simple-stdio-server.ts"],
1062
+ command: "tsx",
1063
+ });
1064
+
1065
+ const stdioClient = new Client(
1066
+ {
1067
+ name: "mcp-proxy",
1068
+ version: "1.0.0",
1069
+ },
1070
+ {
1071
+ capabilities: {},
1072
+ },
1073
+ );
1074
+
1075
+ await stdioClient.connect(stdioTransport);
1076
+
1077
+ const serverVersion = stdioClient.getServerVersion() as {
1078
+ name: string;
1079
+ version: string;
1080
+ };
1081
+
1082
+ const serverCapabilities = stdioClient.getServerCapabilities() as {
1083
+ capabilities: Record<string, unknown>;
1084
+ };
1085
+
1086
+ const port = await getRandomPort();
1087
+
1088
+ // Mock authenticate callback
1089
+ const authenticate = vi.fn().mockResolvedValue({ userId: "user123" });
1090
+
1091
+ const httpServer = await startHTTPServer({
1092
+ authenticate,
1093
+ createServer: async () => {
1094
+ const mcpServer = new Server(serverVersion, {
1095
+ capabilities: serverCapabilities,
1096
+ });
1097
+
1098
+ await proxyServer({
1099
+ client: stdioClient,
1100
+ server: mcpServer,
1101
+ serverCapabilities,
1102
+ });
1103
+
1104
+ return mcpServer;
1105
+ },
1106
+ port,
1107
+ stateless: true, // Enable stateless mode
1108
+ });
1109
+
1110
+ // Create client with Bearer token
1111
+ const streamTransport = new StreamableHTTPClientTransport(
1112
+ new URL(`http://localhost:${port}/mcp`),
1113
+ {
1114
+ requestInit: {
1115
+ headers: {
1116
+ Authorization: "Bearer test-token",
1117
+ },
1118
+ },
1119
+ },
1120
+ );
1121
+
1122
+ const streamClient = new Client(
1123
+ {
1124
+ name: "stream-client-per-request",
1125
+ version: "1.0.0",
1126
+ },
1127
+ {
1128
+ capabilities: {},
1129
+ },
1130
+ );
1131
+
1132
+ await streamClient.connect(streamTransport);
1133
+
1134
+ const initialCallCount = authenticate.mock.calls.length;
1135
+
1136
+ // Make first request
1137
+ await streamClient.listResources();
1138
+ const firstRequestCallCount = authenticate.mock.calls.length;
1139
+
1140
+ // Make second request
1141
+ await streamClient.listResources();
1142
+ const secondRequestCallCount = authenticate.mock.calls.length;
1143
+
1144
+ // In stateless mode, authenticate should be called on EVERY request
1145
+ expect(firstRequestCallCount).toBeGreaterThan(initialCallCount);
1146
+ expect(secondRequestCallCount).toBeGreaterThan(firstRequestCallCount);
1147
+
1148
+ await streamClient.close();
1149
+ await httpServer.close();
1150
+ await stdioClient.close();
1151
+ });
1152
+
1153
+ it("includes Authorization in CORS allowed headers", async () => {
1154
+ const port = await getRandomPort();
1155
+
1156
+ const httpServer = await startHTTPServer({
1157
+ createServer: async () => {
1158
+ const mcpServer = new Server(
1159
+ { name: "test", version: "1.0.0" },
1160
+ { capabilities: {} },
1161
+ );
1162
+ return mcpServer;
1163
+ },
1164
+ port,
1165
+ });
1166
+
1167
+ // Test OPTIONS request to verify CORS headers
1168
+ const response = await fetch(`http://localhost:${port}/mcp`, {
1169
+ headers: {
1170
+ Origin: "https://example.com",
1171
+ },
1172
+ method: "OPTIONS",
1173
+ });
1174
+
1175
+ expect(response.status).toBe(204);
1176
+
1177
+ // Verify Authorization is in the allowed headers
1178
+ const allowedHeaders = response.headers.get("Access-Control-Allow-Headers");
1179
+ expect(allowedHeaders).toBeTruthy();
1180
+ expect(allowedHeaders).toContain("Authorization");
1181
+
1182
+ await httpServer.close();
1183
+ });
@@ -93,6 +93,7 @@ const cleanupServer = async <T extends ServerLike>(
93
93
 
94
94
  const handleStreamRequest = async <T extends ServerLike>({
95
95
  activeTransports,
96
+ authenticate,
96
97
  createServer,
97
98
  enableJsonResponse,
98
99
  endpoint,
@@ -107,6 +108,7 @@ const handleStreamRequest = async <T extends ServerLike>({
107
108
  string,
108
109
  { server: T; transport: StreamableHTTPServerTransport }
109
110
  >;
111
+ authenticate?: (request: http.IncomingMessage) => Promise<unknown>;
110
112
  createServer: (request: http.IncomingMessage) => Promise<T>;
111
113
  enableJsonResponse?: boolean;
112
114
  endpoint: string;
@@ -132,6 +134,41 @@ const handleStreamRequest = async <T extends ServerLike>({
132
134
 
133
135
  const body = await getBody(req);
134
136
 
137
+ // Per-request authentication in stateless mode
138
+ if (stateless && authenticate) {
139
+ try {
140
+ const authResult = await authenticate(req);
141
+ if (!authResult) {
142
+ res.setHeader("Content-Type", "application/json");
143
+ res.writeHead(401).end(
144
+ JSON.stringify({
145
+ error: {
146
+ code: -32000,
147
+ message: "Unauthorized: Authentication failed"
148
+ },
149
+ id: (body as { id?: unknown })?.id ?? null,
150
+ jsonrpc: "2.0"
151
+ })
152
+ );
153
+ return true;
154
+ }
155
+ } catch (error) {
156
+ console.error("Authentication error:", error);
157
+ res.setHeader("Content-Type", "application/json");
158
+ res.writeHead(401).end(
159
+ JSON.stringify({
160
+ error: {
161
+ code: -32000,
162
+ message: "Unauthorized: Authentication error"
163
+ },
164
+ id: (body as { id?: unknown })?.id ?? null,
165
+ jsonrpc: "2.0"
166
+ })
167
+ );
168
+ return true;
169
+ }
170
+ }
171
+
135
172
  if (sessionId) {
136
173
  const activeTransport = activeTransports[sessionId];
137
174
  if (!activeTransport) {
@@ -457,6 +494,7 @@ const handleSSERequest = async <T extends ServerLike>({
457
494
 
458
495
  export const startHTTPServer = async <T extends ServerLike>({
459
496
  apiKey,
497
+ authenticate,
460
498
  createServer,
461
499
  enableJsonResponse,
462
500
  eventStore,
@@ -470,6 +508,7 @@ export const startHTTPServer = async <T extends ServerLike>({
470
508
  streamEndpoint = "/mcp",
471
509
  }: {
472
510
  apiKey?: string;
511
+ authenticate?: (request: http.IncomingMessage) => Promise<unknown>;
473
512
  createServer: (request: http.IncomingMessage) => Promise<T>;
474
513
  enableJsonResponse?: boolean;
475
514
  eventStore?: EventStore;
@@ -508,8 +547,8 @@ export const startHTTPServer = async <T extends ServerLike>({
508
547
  res.setHeader("Access-Control-Allow-Origin", origin.origin);
509
548
  res.setHeader("Access-Control-Allow-Credentials", "true");
510
549
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
511
- res.setHeader("Access-Control-Allow-Headers", "*");
512
- res.setHeader("Access-Control-Expose-Headers", "mcp-session-id");
550
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, Mcp-Session-Id, Last-Event-Id");
551
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
513
552
  } catch (error) {
514
553
  console.error("[mcp-proxy] error parsing origin", error);
515
554
  }
@@ -553,6 +592,7 @@ export const startHTTPServer = async <T extends ServerLike>({
553
592
  streamEndpoint &&
554
593
  (await handleStreamRequest({
555
594
  activeTransports: activeStreamTransports,
595
+ authenticate,
556
596
  createServer,
557
597
  enableJsonResponse,
558
598
  endpoint: streamEndpoint,