From 5a25fe3a6ca5f6a159c249a63fe2f358cda8a9c5 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 8 May 2025 13:31:18 -0400 Subject: [PATCH 1/2] fix: preserve custom paths in SSE endpoint URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an SSE client connects with a custom path (e.g., /api/v1/custom/sse), ensure the endpoint URL maintains the same base path structure but with /messages instead of /sse. This fixes issues where custom endpoints were getting collapsed to the root path. Should fix issues reported in modelcontextprotocol/inspector#313 and #296 Tests demonstrating the issue were added in PR #439 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/client/sse.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/client/sse.ts b/src/client/sse.ts index 5e9f0cf0..1e481773 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -143,7 +143,18 @@ export class SSEClientTransport implements Transport { const messageEvent = event as MessageEvent; try { + // Use the original URL as the base to resolve the received endpoint this._endpoint = new URL(messageEvent.data, this._url); + + // If the original URL had a custom path, preserve it in the endpoint URL + const originalPath = this._url.pathname; + if (originalPath && originalPath !== '/' && originalPath !== '/sse') { + // Extract the base path from the original URL (everything before the /sse suffix) + const basePath = originalPath.replace(/\/sse$/, ''); + // The endpoint should use the same base path but with /messages instead of /sse + this._endpoint.pathname = basePath + '/messages'; + } + if (this._endpoint.origin !== this._url.origin) { throw new Error( `Endpoint origin does not match connection origin: ${this._endpoint.origin}`, From 2f522bf3707222ed75b0105c369ce8989f437cac Mon Sep 17 00:00:00 2001 From: olaservo Date: Fri, 2 May 2025 06:36:33 -0700 Subject: [PATCH 2/2] Add failing tests --- src/client/sse.test.ts | 153 +++++++++++++++++++++++++++++------------ 1 file changed, 109 insertions(+), 44 deletions(-) diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 77b28508..fc7a86c4 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -68,6 +68,71 @@ describe("SSEClientTransport", () => { }); describe("connection handling", () => { + it("maintains custom path when constructing endpoint URL", async () => { + // Create a URL with a custom path + const customPathUrl = new URL("/custom/path/sse", baseUrl); + transport = new SSEClientTransport(customPathUrl); + + // Start the transport + await transport.start(); + + // Send a test message to verify the endpoint URL + const message: JSONRPCMessage = { + jsonrpc: "2.0", + id: "test-1", + method: "test", + params: {} + }; + + await transport.send(message); + + // Verify the POST request maintains the custom path + expect(lastServerRequest.url).toBe("/custom/path/messages"); + }); + + it("handles multiple levels of custom paths", async () => { + // Test with a deeper nested path + const nestedPathUrl = new URL("/api/v1/custom/deep/path/sse", baseUrl); + transport = new SSEClientTransport(nestedPathUrl); + + await transport.start(); + + const message: JSONRPCMessage = { + jsonrpc: "2.0", + id: "test-1", + method: "test", + params: {} + }; + + await transport.send(message); + + // Verify the POST request maintains the full custom path + expect(lastServerRequest.url).toBe("/api/v1/custom/deep/path/messages"); + }); + + it("maintains custom path for SSE connection", async () => { + const customPathUrl = new URL("/custom/path/sse", baseUrl); + transport = new SSEClientTransport(customPathUrl); + await transport.start(); + expect(lastServerRequest.url).toBe("/custom/path/sse"); + }); + + it("handles URLs with query parameters", async () => { + const urlWithQuery = new URL("/custom/path/sse?param=value", baseUrl); + transport = new SSEClientTransport(urlWithQuery); + await transport.start(); + + const message: JSONRPCMessage = { + jsonrpc: "2.0", + id: "test-1", + method: "test", + params: {} + }; + + await transport.send(message); + expect(lastServerRequest.url).toBe("/custom/path/messages"); + }); + it("establishes SSE connection and receives endpoint", async () => { transport = new SSEClientTransport(baseUrl); await transport.start(); @@ -397,18 +462,18 @@ describe("SSEClientTransport", () => { return; } - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - }); - res.write("event: endpoint\n"); - res.write(`data: ${baseUrl.href}\n\n`); + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }); + res.write("event: endpoint\n"); + res.write(`data: ${baseUrl.href}\n\n`); break; case "POST": - res.writeHead(401); - res.end(); + res.writeHead(401); + res.end(); break; } }); @@ -517,25 +582,25 @@ describe("SSEClientTransport", () => { return; } - const auth = req.headers.authorization; - if (auth === "Bearer expired-token") { - res.writeHead(401).end(); - return; - } + const auth = req.headers.authorization; + if (auth === "Bearer expired-token") { + res.writeHead(401).end(); + return; + } - if (auth === "Bearer new-token") { - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - }); - res.write("event: endpoint\n"); - res.write(`data: ${baseUrl.href}\n\n`); - connectionAttempts++; - return; - } + if (auth === "Bearer new-token") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }); + res.write("event: endpoint\n"); + res.write(`data: ${baseUrl.href}\n\n`); + connectionAttempts++; + return; + } - res.writeHead(401).end(); + res.writeHead(401).end(); }); await new Promise(resolve => { @@ -610,13 +675,13 @@ describe("SSEClientTransport", () => { return; } - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - }); - res.write("event: endpoint\n"); - res.write(`data: ${baseUrl.href}\n\n`); + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }); + res.write("event: endpoint\n"); + res.write(`data: ${baseUrl.href}\n\n`); break; case "POST": { @@ -625,19 +690,19 @@ describe("SSEClientTransport", () => { return; } - const auth = req.headers.authorization; - if (auth === "Bearer expired-token") { - res.writeHead(401).end(); - return; - } + const auth = req.headers.authorization; + if (auth === "Bearer expired-token") { + res.writeHead(401).end(); + return; + } - if (auth === "Bearer new-token") { - res.writeHead(200).end(); - postAttempts++; - return; - } + if (auth === "Bearer new-token") { + res.writeHead(200).end(); + postAttempts++; + return; + } - res.writeHead(401).end(); + res.writeHead(401).end(); break; } }