diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..e69de29b diff --git a/client/src/App.tsx b/client/src/App.tsx index 8aca8697..054a4f69 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -349,9 +349,9 @@ const App = () => { const headers: HeadersInit = {}; const proxyAuthToken = getMCPProxyAuthToken(config); if (proxyAuthToken) { - headers['Authorization'] = `Bearer ${proxyAuthToken}`; + headers["Authorization"] = `Bearer ${proxyAuthToken}`; } - + fetch(`${getMCPProxyAddress(config)}/config`, { headers }) .then((response) => response.json()) .then((data) => { diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 2130e68b..eb7ff3ba 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -84,12 +84,6 @@ Object.defineProperty(window, "sessionStorage", { value: sessionStorageMock, }); -Object.defineProperty(window, "location", { - value: { - origin: "http://localhost:3000", - }, -}); - describe("AuthDebugger", () => { const defaultAuthState = EMPTY_DEBUGGER_STATE; @@ -104,7 +98,7 @@ describe("AuthDebugger", () => { jest.clearAllMocks(); sessionStorageMock.getItem.mockReturnValue(null); - // Supress + // Suppress console errors in tests to avoid JSDOM navigation noise jest.spyOn(console, "error").mockImplementation(() => {}); mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); @@ -447,18 +441,6 @@ describe("AuthDebugger", () => { it("should store auth state to sessionStorage before redirect in Quick OAuth Flow", async () => { const updateAuthState = jest.fn(); - // Mock window.location.href setter - const originalLocation = window.location; - const locationMock = { - ...originalLocation, - href: "", - origin: "http://localhost:3000", - }; - Object.defineProperty(window, "location", { - writable: true, - value: locationMock, - }); - // Setup mocks for OAuth flow mockStartAuthorization.mockResolvedValue({ authorizationUrl: new URL( diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index ba03d180..d818bdbb 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -519,6 +519,7 @@ describe("Sidebar Environment Variables", () => { label: "Request Timeout", description: "Timeout for requests to the MCP server (ms)", value: 5000, + is_session_item: false, }, }), ); @@ -544,6 +545,7 @@ describe("Sidebar Environment Variables", () => { description: "Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577", value: "http://localhost:8080", + is_session_item: false, }, }), ); @@ -569,6 +571,7 @@ describe("Sidebar Environment Variables", () => { description: "Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)", value: 10000, + is_session_item: false, }, }), ); @@ -591,6 +594,7 @@ describe("Sidebar Environment Variables", () => { label: "Request Timeout", description: "Timeout for requests to the MCP server (ms)", value: 0, + is_session_item: false, }, }), ); @@ -637,6 +641,7 @@ describe("Sidebar Environment Variables", () => { label: "Request Timeout", description: "Timeout for requests to the MCP server (ms)", value: 3000, + is_session_item: false, }, }), ); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index c2eccb72..472b391f 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -246,7 +246,7 @@ export function useConnection({ const proxyAuthToken = getMCPProxyAuthToken(config); const headers: HeadersInit = {}; if (proxyAuthToken) { - headers['Authorization'] = `Bearer ${proxyAuthToken}`; + headers["Authorization"] = `Bearer ${proxyAuthToken}`; } const proxyHealthResponse = await fetch(proxyHealthUrl, { headers }); const proxyHealth = await proxyHealthResponse.json(); @@ -269,7 +269,7 @@ export function useConnection({ const isProxyAuthError = (error: unknown): boolean => { return ( - error instanceof Error && + error instanceof Error && error.message.includes("Authentication required. Use the session token") ); }; @@ -335,7 +335,7 @@ export function useConnection({ const proxyAuthToken = getMCPProxyAuthToken(config); const proxyHeaders: HeadersInit = {}; if (proxyAuthToken) { - proxyHeaders['Authorization'] = `Bearer ${proxyAuthToken}`; + proxyHeaders["Authorization"] = `Bearer ${proxyAuthToken}`; } // Create appropriate transport @@ -356,7 +356,11 @@ export function useConnection({ fetch: ( url: string | URL | globalThis.Request, init: RequestInit | undefined, - ) => fetch(url, { ...init, headers: { ...headers, ...proxyHeaders } }), + ) => + fetch(url, { + ...init, + headers: { ...headers, ...proxyHeaders }, + }), }, requestInit: { headers: { ...headers, ...proxyHeaders }, @@ -372,7 +376,11 @@ export function useConnection({ fetch: ( url: string | URL | globalThis.Request, init: RequestInit | undefined, - ) => fetch(url, { ...init, headers: { ...headers, ...proxyHeaders } }), + ) => + fetch(url, { + ...init, + headers: { ...headers, ...proxyHeaders }, + }), }, requestInit: { headers: { ...headers, ...proxyHeaders }, @@ -388,7 +396,11 @@ export function useConnection({ fetch: ( url: string | URL | globalThis.Request, init: RequestInit | undefined, - ) => fetch(url, { ...init, headers: { ...headers, ...proxyHeaders } }), + ) => + fetch(url, { + ...init, + headers: { ...headers, ...proxyHeaders }, + }), }, requestInit: { headers: { ...headers, ...proxyHeaders }, @@ -471,7 +483,8 @@ export function useConnection({ if (isProxyAuthError(error)) { toast({ title: "Proxy Authentication Required", - description: "Please enter the session token from the proxy server console in the Configuration settings.", + description: + "Please enter the session token from the proxy server console in the Configuration settings.", variant: "destructive", }); setConnectionStatus("error"); diff --git a/package-lock.json b/package-lock.json index 846d2eaa..95fc6f69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.14.0", + "version": "0.14.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "0.14.0", + "version": "0.14.1", "license": "MIT", "workspaces": [ "client", diff --git a/server/src/index.ts b/server/src/index.ts index a6ca2f42..9afdb1c6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -89,46 +89,57 @@ app.use((req, res, next) => { const webAppTransports: Map = new Map(); // Web app transports by web app sessionId const serverTransports: Map = new Map(); // Server Transports by web app sessionId -const sessionToken = randomBytes(32).toString('hex'); +const sessionToken = randomBytes(32).toString("hex"); const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH; // Origin validation middleware to prevent DNS rebinding attacks -const originValidationMiddleware = (req: express.Request, res: express.Response, next: express.NextFunction) => { +const originValidationMiddleware = ( + req: express.Request, + res: express.Response, + next: express.NextFunction, +) => { const origin = req.headers.origin; - + // Default origins based on CLIENT_PORT or use environment variable - const clientPort = process.env.CLIENT_PORT || '6274'; + const clientPort = process.env.CLIENT_PORT || "6274"; const defaultOrigins = [ `http://localhost:${clientPort}`, - `http://127.0.0.1:${clientPort}` + `http://127.0.0.1:${clientPort}`, ]; - const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || defaultOrigins; - + const allowedOrigins = + process.env.ALLOWED_ORIGINS?.split(",") || defaultOrigins; + if (origin && !allowedOrigins.includes(origin)) { console.error(`Invalid origin: ${origin}`); - res.status(403).json({ - error: 'Forbidden - invalid origin', - message: 'Request blocked to prevent DNS rebinding attacks. Configure allowed origins via environment variable.' + res.status(403).json({ + error: "Forbidden - invalid origin", + message: + "Request blocked to prevent DNS rebinding attacks. Configure allowed origins via environment variable.", }); return; } next(); }; -const authMiddleware = (req: express.Request, res: express.Response, next: express.NextFunction) => { +const authMiddleware = ( + req: express.Request, + res: express.Response, + next: express.NextFunction, +) => { if (authDisabled) { return next(); } const sendUnauthorized = () => { - res.status(401).json({ - error: "Unauthorized", - message: "Authentication required. Use the session token shown in the console when starting the server." + res.status(401).json({ + error: "Unauthorized", + message: + "Authentication required. Use the session token shown in the console when starting the server.", }); }; const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { + if (!authHeader || !authHeader.startsWith("Bearer ")) { sendUnauthorized(); return; } @@ -151,7 +162,7 @@ const authMiddleware = (req: express.Request, res: express.Response, next: expre sendUnauthorized(); return; } - + next(); }; @@ -216,38 +227,152 @@ const createTransport = async (req: express.Request): Promise => { } }; -app.get("/mcp", originValidationMiddleware, authMiddleware, async (req, res) => { - const sessionId = req.headers["mcp-session-id"] as string; - console.log(`Received GET message for sessionId ${sessionId}`); - try { - const transport = webAppTransports.get( - sessionId, - ) as StreamableHTTPServerTransport; - if (!transport) { - res.status(404).end("Session not found"); - return; - } else { - await transport.handleRequest(req, res); - } - } catch (error) { - console.error("Error in /mcp route:", error); - res.status(500).json(error); - } -}); - -app.post("/mcp", originValidationMiddleware, authMiddleware, async (req, res) => { - const sessionId = req.headers["mcp-session-id"] as string | undefined; - let serverTransport: Transport | undefined; - if (!sessionId) { +app.get( + "/mcp", + originValidationMiddleware, + authMiddleware, + async (req, res) => { + const sessionId = req.headers["mcp-session-id"] as string; + console.log(`Received GET message for sessionId ${sessionId}`); try { - console.log("New StreamableHttp connection request"); + const transport = webAppTransports.get( + sessionId, + ) as StreamableHTTPServerTransport; + if (!transport) { + res.status(404).end("Session not found"); + return; + } else { + await transport.handleRequest(req, res); + } + } catch (error) { + console.error("Error in /mcp route:", error); + res.status(500).json(error); + } + }, +); + +app.post( + "/mcp", + originValidationMiddleware, + authMiddleware, + async (req, res) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + let serverTransport: Transport | undefined; + if (!sessionId) { + try { + console.log("New StreamableHttp connection request"); + try { + serverTransport = await createTransport(req); + } catch (error) { + if (error instanceof SseError && error.code === 401) { + console.error( + "Received 401 Unauthorized from MCP server:", + error.message, + ); + res.status(401).json(error); + return; + } + + throw error; + } + + console.log("Created StreamableHttp server transport"); + + const webAppTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: randomUUID, + onsessioninitialized: (sessionId) => { + webAppTransports.set(sessionId, webAppTransport); + serverTransports.set(sessionId, serverTransport!); + console.log("Client <-> Proxy sessionId: " + sessionId); + }, + }); + console.log("Created StreamableHttp client transport"); + + await webAppTransport.start(); + + mcpProxy({ + transportToClient: webAppTransport, + transportToServer: serverTransport, + }); + + await (webAppTransport as StreamableHTTPServerTransport).handleRequest( + req, + res, + req.body, + ); + } catch (error) { + console.error("Error in /mcp POST route:", error); + res.status(500).json(error); + } + } else { + console.log(`Received POST message for sessionId ${sessionId}`); + try { + const transport = webAppTransports.get( + sessionId, + ) as StreamableHTTPServerTransport; + if (!transport) { + res.status(404).end("Transport not found for sessionId " + sessionId); + } else { + await (transport as StreamableHTTPServerTransport).handleRequest( + req, + res, + ); + } + } catch (error) { + console.error("Error in /mcp route:", error); + res.status(500).json(error); + } + } + }, +); + +app.delete( + "/mcp", + originValidationMiddleware, + authMiddleware, + async (req, res) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + console.log(`Received DELETE message for sessionId ${sessionId}`); + let serverTransport: Transport | undefined; + if (sessionId) { + try { + serverTransport = serverTransports.get( + sessionId, + ) as StreamableHTTPClientTransport; + if (!serverTransport) { + res.status(404).end("Transport not found for sessionId " + sessionId); + } else { + await ( + serverTransport as StreamableHTTPClientTransport + ).terminateSession(); + webAppTransports.delete(sessionId); + serverTransports.delete(sessionId); + console.log(`Transports removed for sessionId ${sessionId}`); + } + res.status(200).end(); + } catch (error) { + console.error("Error in /mcp route:", error); + res.status(500).json(error); + } + } + }, +); + +app.get( + "/stdio", + originValidationMiddleware, + authMiddleware, + async (req, res) => { + try { + console.log("New STDIO connection request"); + let serverTransport: Transport | undefined; try { serverTransport = await createTransport(req); + console.log("Created server transport"); } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( - "Received 401 Unauthorized from MCP server:", - error.message, + "Received 401 Unauthorized from MCP server. Authentication failure.", ); res.status(401).json(error); return; @@ -256,211 +381,127 @@ app.post("/mcp", originValidationMiddleware, authMiddleware, async (req, res) => throw error; } - console.log("Created StreamableHttp server transport"); - - const webAppTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: randomUUID, - onsessioninitialized: (sessionId) => { - webAppTransports.set(sessionId, webAppTransport); - serverTransports.set(sessionId, serverTransport!); - console.log("Client <-> Proxy sessionId: " + sessionId); - }, - }); - console.log("Created StreamableHttp client transport"); - - await webAppTransport.start(); - - mcpProxy({ - transportToClient: webAppTransport, - transportToServer: serverTransport, - }); - - await (webAppTransport as StreamableHTTPServerTransport).handleRequest( - req, - res, - req.body, - ); - } catch (error) { - console.error("Error in /mcp POST route:", error); - res.status(500).json(error); - } - } else { - console.log(`Received POST message for sessionId ${sessionId}`); - try { - const transport = webAppTransports.get( - sessionId, - ) as StreamableHTTPServerTransport; - if (!transport) { - res.status(404).end("Transport not found for sessionId " + sessionId); - } else { - await (transport as StreamableHTTPServerTransport).handleRequest( - req, - res, - ); - } - } catch (error) { - console.error("Error in /mcp route:", error); - res.status(500).json(error); - } - } -}); - -app.delete("/mcp", originValidationMiddleware, authMiddleware, async (req, res) => { - const sessionId = req.headers["mcp-session-id"] as string | undefined; - console.log(`Received DELETE message for sessionId ${sessionId}`); - let serverTransport: Transport | undefined; - if (sessionId) { - try { - serverTransport = serverTransports.get( - sessionId, - ) as StreamableHTTPClientTransport; - if (!serverTransport) { - res.status(404).end("Transport not found for sessionId " + sessionId); - } else { - await ( - serverTransport as StreamableHTTPClientTransport - ).terminateSession(); - webAppTransports.delete(sessionId); - serverTransports.delete(sessionId); - console.log(`Transports removed for sessionId ${sessionId}`); - } - res.status(200).end(); - } catch (error) { - console.error("Error in /mcp route:", error); - res.status(500).json(error); - } - } -}); - -app.get("/stdio", originValidationMiddleware, authMiddleware, async (req, res) => { - try { - console.log("New STDIO connection request"); - let serverTransport: Transport | undefined; - try { - serverTransport = await createTransport(req); - console.log("Created server transport"); - } catch (error) { - if (error instanceof SseError && error.code === 401) { - console.error( - "Received 401 Unauthorized from MCP server. Authentication failure.", - ); - res.status(401).json(error); - return; - } - - throw error; - } - - const webAppTransport = new SSEServerTransport("/message", res); - console.log("Created client transport"); - - webAppTransports.set(webAppTransport.sessionId, webAppTransport); - serverTransports.set(webAppTransport.sessionId, serverTransport); - - await webAppTransport.start(); - - (serverTransport as StdioClientTransport).stderr!.on("data", (chunk) => { - if (chunk.toString().includes("MODULE_NOT_FOUND")) { - webAppTransport.send({ - jsonrpc: "2.0", - method: "notifications/stderr", - params: { - content: "Command not found, transports removed", - }, - }); - webAppTransport.close(); - serverTransport.close(); - webAppTransports.delete(webAppTransport.sessionId); - serverTransports.delete(webAppTransport.sessionId); - console.error("Command not found, transports removed"); - } else { - webAppTransport.send({ - jsonrpc: "2.0", - method: "notifications/stderr", - params: { - content: chunk.toString(), - }, - }); - } - }); - - mcpProxy({ - transportToClient: webAppTransport, - transportToServer: serverTransport, - }); - } catch (error) { - console.error("Error in /stdio route:", error); - res.status(500).json(error); - } -}); - -app.get("/sse", originValidationMiddleware, authMiddleware, async (req, res) => { - try { - console.log( - "New SSE connection request. NOTE: The sse transport is deprecated and has been replaced by StreamableHttp", - ); - let serverTransport: Transport | undefined; - try { - serverTransport = await createTransport(req); - } catch (error) { - if (error instanceof SseError && error.code === 401) { - console.error( - "Received 401 Unauthorized from MCP server. Authentication failure.", - ); - res.status(401).json(error); - return; - } else if (error instanceof SseError && error.code === 404) { - console.error( - "Received 404 not found from MCP server. Does the MCP server support SSE?", - ); - res.status(404).json(error); - return; - } else if (JSON.stringify(error).includes("ECONNREFUSED")) { - console.error("Connection refused. Is the MCP server running?"); - res.status(500).json(error); - } else { - throw error; - } - } - - if (serverTransport) { const webAppTransport = new SSEServerTransport("/message", res); - webAppTransports.set(webAppTransport.sessionId, webAppTransport); console.log("Created client transport"); - serverTransports.set(webAppTransport.sessionId, serverTransport!); - console.log("Created server transport"); + + webAppTransports.set(webAppTransport.sessionId, webAppTransport); + serverTransports.set(webAppTransport.sessionId, serverTransport); await webAppTransport.start(); + (serverTransport as StdioClientTransport).stderr!.on("data", (chunk) => { + if (chunk.toString().includes("MODULE_NOT_FOUND")) { + webAppTransport.send({ + jsonrpc: "2.0", + method: "notifications/stderr", + params: { + content: "Command not found, transports removed", + }, + }); + webAppTransport.close(); + serverTransport.close(); + webAppTransports.delete(webAppTransport.sessionId); + serverTransports.delete(webAppTransport.sessionId); + console.error("Command not found, transports removed"); + } else { + webAppTransport.send({ + jsonrpc: "2.0", + method: "notifications/stderr", + params: { + content: chunk.toString(), + }, + }); + } + }); + mcpProxy({ transportToClient: webAppTransport, transportToServer: serverTransport, }); + } catch (error) { + console.error("Error in /stdio route:", error); + res.status(500).json(error); } - } catch (error) { - console.error("Error in /sse route:", error); - res.status(500).json(error); - } -}); + }, +); -app.post("/message", originValidationMiddleware, authMiddleware, async (req, res) => { - try { - const sessionId = req.query.sessionId; - console.log(`Received POST message for sessionId ${sessionId}`); +app.get( + "/sse", + originValidationMiddleware, + authMiddleware, + async (req, res) => { + try { + console.log( + "New SSE connection request. NOTE: The sse transport is deprecated and has been replaced by StreamableHttp", + ); + let serverTransport: Transport | undefined; + try { + serverTransport = await createTransport(req); + } catch (error) { + if (error instanceof SseError && error.code === 401) { + console.error( + "Received 401 Unauthorized from MCP server. Authentication failure.", + ); + res.status(401).json(error); + return; + } else if (error instanceof SseError && error.code === 404) { + console.error( + "Received 404 not found from MCP server. Does the MCP server support SSE?", + ); + res.status(404).json(error); + return; + } else if (JSON.stringify(error).includes("ECONNREFUSED")) { + console.error("Connection refused. Is the MCP server running?"); + res.status(500).json(error); + } else { + throw error; + } + } - const transport = webAppTransports.get( - sessionId as string, - ) as SSEServerTransport; - if (!transport) { - res.status(404).end("Session not found"); - return; + if (serverTransport) { + const webAppTransport = new SSEServerTransport("/message", res); + webAppTransports.set(webAppTransport.sessionId, webAppTransport); + console.log("Created client transport"); + serverTransports.set(webAppTransport.sessionId, serverTransport!); + console.log("Created server transport"); + + await webAppTransport.start(); + + mcpProxy({ + transportToClient: webAppTransport, + transportToServer: serverTransport, + }); + } + } catch (error) { + console.error("Error in /sse route:", error); + res.status(500).json(error); } - await transport.handlePostMessage(req, res); - } catch (error) { - console.error("Error in /message route:", error); - res.status(500).json(error); - } -}); + }, +); + +app.post( + "/message", + originValidationMiddleware, + authMiddleware, + async (req, res) => { + try { + const sessionId = req.query.sessionId; + console.log(`Received POST message for sessionId ${sessionId}`); + + const transport = webAppTransports.get( + sessionId as string, + ) as SSEServerTransport; + if (!transport) { + res.status(404).end("Session not found"); + return; + } + await transport.handlePostMessage(req, res); + } catch (error) { + console.error("Error in /message route:", error); + res.status(500).json(error); + } + }, +); app.get("/health", (req, res) => { res.json({ @@ -481,22 +522,28 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => { } }); -const PORT = parseInt(process.env.PORT || '6277', 10); -const HOST = process.env.HOST || '127.0.0.1'; +const PORT = parseInt(process.env.PORT || "6277", 10); +const HOST = process.env.HOST || "127.0.0.1"; const server = app.listen(PORT, HOST); server.on("listening", () => { console.log(`āš™ļø Proxy server listening on ${HOST}:${PORT}`); if (!authDisabled) { console.log(`šŸ”‘ Session token: ${sessionToken}`); - console.log(`Use this token to authenticate requests or set DANGEROUSLY_OMIT_AUTH=true to disable auth`); - + console.log( + `Use this token to authenticate requests or set DANGEROUSLY_OMIT_AUTH=true to disable auth`, + ); + // Display clickable URL with pre-filled token - const clientPort = process.env.CLIENT_PORT || '6274'; + const clientPort = process.env.CLIENT_PORT || "6274"; const clientUrl = `http://localhost:${clientPort}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`; - console.log(`\nšŸ”— Open inspector with token pre-filled:\n ${clientUrl}\n (Auto-open is disabled when authentication is enabled)\n`); + console.log( + `\nšŸ”— Open inspector with token pre-filled:\n ${clientUrl}\n (Auto-open is disabled when authentication is enabled)\n`, + ); } else { - console.log(`āš ļø WARNING: Authentication is disabled. This is not recommended.`); + console.log( + `āš ļø WARNING: Authentication is disabled. This is not recommended.`, + ); } }); server.on("error", (err) => {