mcp-proxy 6.4.4 → 6.4.6

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.4.4"
6
+ "version": "6.4.6"
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-proxy",
3
- "version": "6.4.4",
3
+ "version": "6.4.6",
4
4
  "main": "dist/index.mjs",
5
5
  "scripts": {
6
6
  "build": "tsdown",
@@ -692,6 +692,115 @@ it("does not require auth for OPTIONS requests", async () => {
692
692
  await httpServer.close();
693
693
  });
694
694
 
695
+ it("allows onUnhandledRequest to serve routes without auth", async () => {
696
+ const port = await getRandomPort();
697
+ const apiKey = "test-api-key-unhandled";
698
+
699
+ const httpServer = await startHTTPServer({
700
+ apiKey,
701
+ createServer: async () => {
702
+ const mcpServer = new Server(
703
+ { name: "test", version: "1.0.0" },
704
+ { capabilities: {} },
705
+ );
706
+ return mcpServer;
707
+ },
708
+ onUnhandledRequest: async (req, res) => {
709
+ if (req.url === "/health") {
710
+ res.writeHead(200).end("ok");
711
+ } else if (req.url === "/ready") {
712
+ res.writeHead(200).end("ready");
713
+ }
714
+ // Don't write response for unknown paths — fall through to MCP handlers
715
+ },
716
+ port,
717
+ });
718
+
719
+ // /health works without auth
720
+ const healthResponse = await fetch(`http://localhost:${port}/health`);
721
+ expect(healthResponse.status).toBe(200);
722
+ expect(await healthResponse.text()).toBe("ok");
723
+
724
+ // /ready works without auth
725
+ const readyResponse = await fetch(`http://localhost:${port}/ready`);
726
+ expect(readyResponse.status).toBe(200);
727
+ expect(await readyResponse.text()).toBe("ready");
728
+
729
+ // POST /mcp without auth still returns 401
730
+ const mcpResponse = await fetch(`http://localhost:${port}/mcp`, {
731
+ body: JSON.stringify({
732
+ id: 1,
733
+ jsonrpc: "2.0",
734
+ method: "initialize",
735
+ params: {
736
+ capabilities: {},
737
+ clientInfo: { name: "test", version: "1.0.0" },
738
+ protocolVersion: "2025-03-26",
739
+ },
740
+ }),
741
+ headers: { "Content-Type": "application/json" },
742
+ method: "POST",
743
+ });
744
+ expect(mcpResponse.status).toBe(401);
745
+
746
+ await httpServer.close();
747
+ });
748
+
749
+ it("routes MCP stream endpoint to handleStreamRequest even when onUnhandledRequest closes response for unknown paths", async () => {
750
+ // Regression test for the interaction between PR #59 and consumers
751
+ // (e.g. fastmcp) whose onUnhandledRequest handler writes 404 for any path
752
+ // it doesn't recognise. Before the fix, the POST /mcp request was served
753
+ // by onUnhandledRequest (→ 404) and never reached handleStreamRequest.
754
+ const port = await getRandomPort();
755
+
756
+ const httpServer = await startHTTPServer({
757
+ createServer: async () => {
758
+ return new Server(
759
+ { name: "test", version: "1.0.0" },
760
+ { capabilities: {} },
761
+ );
762
+ },
763
+ // Simulates fastmcp's handleUnhandledRequest: consumes unknown paths
764
+ // with a 404 because it assumes it runs *after* the MCP protocol handlers.
765
+ onUnhandledRequest: async (req, res) => {
766
+ if (req.url === "/health") {
767
+ res.writeHead(200).end("ok");
768
+ return;
769
+ }
770
+ res.writeHead(404).end();
771
+ },
772
+ port,
773
+ });
774
+
775
+ // Sanity: custom route still works (preserves PR #59 behaviour).
776
+ const healthResponse = await fetch(`http://localhost:${port}/health`);
777
+ expect(healthResponse.status).toBe(200);
778
+
779
+ // The MCP initialize call must reach handleStreamRequest, NOT the 404
780
+ // fallback inside onUnhandledRequest.
781
+ const mcpResponse = await fetch(`http://localhost:${port}/mcp`, {
782
+ body: JSON.stringify({
783
+ id: 1,
784
+ jsonrpc: "2.0",
785
+ method: "initialize",
786
+ params: {
787
+ capabilities: {},
788
+ clientInfo: { name: "test", version: "1.0.0" },
789
+ protocolVersion: "2025-03-26",
790
+ },
791
+ }),
792
+ headers: {
793
+ Accept: "application/json, text/event-stream",
794
+ "Content-Type": "application/json",
795
+ },
796
+ method: "POST",
797
+ });
798
+ expect(mcpResponse.status).toBe(200);
799
+ expect(mcpResponse.headers.get("mcp-session-id")).toBeTruthy();
800
+
801
+ await httpServer.close();
802
+ });
803
+
695
804
  // Stateless OAuth 2.0 JWT Bearer Token Authentication Tests (PR #37)
696
805
 
697
806
  it("accepts requests with valid Bearer token in stateless mode", async () => {
@@ -938,7 +938,27 @@ export const startHTTPServer = async <T extends ServerLike>({
938
938
  return;
939
939
  }
940
940
 
941
- // Check authentication for all other endpoints
941
+ // Determine whether the request targets an MCP protocol endpoint (SSE
942
+ // or HTTP Stream). For those endpoints, onUnhandledRequest MUST NOT run
943
+ // first — some consumers (e.g. fastmcp) use it as a catch-all 404 handler
944
+ // and would otherwise short-circuit the MCP protocol handlers.
945
+ // Use a fixed base because `host` may be "::" (IPv6 any), which is not a
946
+ // valid URL authority. We only need pathname here.
947
+ const requestUrl = new URL(req.url || "", "http://localhost");
948
+ const isMcpEndpoint =
949
+ (sseEndpoint && requestUrl.pathname === sseEndpoint) ||
950
+ (streamEndpoint && requestUrl.pathname === streamEndpoint);
951
+
952
+ // Let non-MCP routes (e.g. /health, /ready, OAuth metadata) be handled
953
+ // before auth — API key auth protects MCP protocol endpoints, not custom routes.
954
+ if (onUnhandledRequest && !isMcpEndpoint) {
955
+ await onUnhandledRequest(req, res);
956
+ if (res.writableEnded) {
957
+ return;
958
+ }
959
+ }
960
+
961
+ // Check authentication for MCP protocol endpoints
942
962
  if (!authMiddleware.validateRequest(req)) {
943
963
  const authResponse = authMiddleware.getUnauthorizedResponse();
944
964
  res.writeHead(401, authResponse.headers);
@@ -982,11 +1002,7 @@ export const startHTTPServer = async <T extends ServerLike>({
982
1002
  return;
983
1003
  }
984
1004
 
985
- if (onUnhandledRequest) {
986
- await onUnhandledRequest(req, res);
987
- } else {
988
- res.writeHead(404).end();
989
- }
1005
+ res.writeHead(404).end();
990
1006
  };
991
1007
 
992
1008
  let httpServer;