diff --git a/client/e2e/fixtures/unsupported-protocol-server.js b/client/e2e/fixtures/unsupported-protocol-server.js new file mode 100644 index 000000000..fbfa76a5f --- /dev/null +++ b/client/e2e/fixtures/unsupported-protocol-server.js @@ -0,0 +1,89 @@ +import http from "node:http"; +import { once } from "node:events"; + +const ERROR_MESSAGE = + "Unsupported protocol version: 2025-11-25 - supported versions: 2025-06-18,2025-03-26,2024-11-05,2024-10-07"; + +const applyCors = (res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader( + "Access-Control-Allow-Headers", + "content-type, accept, mcp-session-id, mcp-protocol-version, authorization", + ); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); +}; + +export async function startUnsupportedProtocolServer() { + const server = http.createServer(async (req, res) => { + applyCors(res); + + if (req.method === "OPTIONS") { + res.statusCode = 204; + res.end(); + return; + } + + // Streamable HTTP transport does an optional GET to establish an SSE stream. + // Returning 405 is an expected case handled by the SDK. + if (req.method === "GET") { + res.statusCode = 405; + res.end(); + return; + } + + // Accepts any path; the Inspector URL field can include arbitrary endpoints. + if (req.method !== "POST") { + res.statusCode = 404; + res.end(); + return; + } + + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + await once(req, "end"); + + let parsed; + try { + parsed = JSON.parse(body); + } catch { + res.statusCode = 400; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ error: "Invalid JSON" })); + return; + } + + res.statusCode = 200; + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + id: parsed?.id ?? null, + error: { + code: -32602, + message: ERROR_MESSAGE, + }, + }), + ); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to start unsupported protocol fixture server"); + } + + const baseUrl = `http://127.0.0.1:${address.port}`; + + return { + baseUrl, + close: () => + new Promise((resolve, reject) => + server.close((e) => (e ? reject(e) : resolve())), + ), + }; +} diff --git a/client/e2e/protocol-version-error.spec.ts b/client/e2e/protocol-version-error.spec.ts new file mode 100644 index 000000000..b05461a52 --- /dev/null +++ b/client/e2e/protocol-version-error.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from "@playwright/test"; +import { startUnsupportedProtocolServer } from "./fixtures/unsupported-protocol-server.js"; + +const APP_URL = "http://localhost:6274/"; + +test.describe("Protocol version negotiation errors", () => { + test("surfaces -32602 initialize error and hides proxy token hint", async ({ + page, + }) => { + const fixture = await startUnsupportedProtocolServer(); + try { + await page.goto(APP_URL); + + const transportSelect = page.getByLabel("Transport Type"); + await expect(transportSelect).toBeVisible(); + await transportSelect.click(); + await page.getByRole("option", { name: "Streamable HTTP" }).click(); + + const connectionTypeSelect = page.getByLabel("Connection Type"); + await expect(connectionTypeSelect).toBeVisible(); + await connectionTypeSelect.click(); + await page.getByRole("option", { name: "Direct" }).click(); + + await page.locator("#sse-url-input").fill(fixture.baseUrl); + + await page.getByRole("button", { name: "Connect" }).click(); + + await expect(page.getByText(/MCP error\s*-32602/i).first()).toBeVisible({ + timeout: 10000, + }); + + await expect( + page.getByText(/Did you add the proxy session token in Configuration/i), + ).toHaveCount(0); + + await expect( + page.locator('[data-testid="connection-error-details"]'), + ).toBeVisible(); + } finally { + await fixture.close(); + } + }); +}); diff --git a/client/src/App.tsx b/client/src/App.tsx index a9f99686d..a995a18f5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -299,6 +299,7 @@ const App = () => { const { connectionStatus, + connectionError, serverCapabilities, serverImplementation, mcpClient, @@ -956,6 +957,7 @@ const App = () => { > void; command: string; @@ -80,6 +85,7 @@ interface SidebarProps { const Sidebar = ({ connectionStatus, + connectionError, transportType, setTransportType, command, @@ -119,6 +125,22 @@ const Sidebar = ({ const [copiedServerFile, setCopiedServerFile] = useState(false); const { toast } = useToast(); + const mcpError = connectionError ? getMcpErrorInfo(connectionError) : null; + const mcpErrorDisplayMessage = mcpError + ? mcpError.message + .replace(/^McpError:\s*/i, "") + .replace(/^MCP error\s+-?\d+:\s*/i, "") + : ""; + const protocolVersionDetails = mcpError + ? parseUnsupportedProtocolVersionError(mcpError.message) + : null; + + const shouldShowProxyTokenHint = + connectionStatus === "error" && + connectionType === "proxy" && + !mcpError && + !config.MCP_PROXY_AUTH_TOKEN?.value; + const connectionTypeTip = "Connect to server directly (requires CORS config on server) or via MCP Inspector Proxy"; // Reusable error reporter for copy actions @@ -753,7 +775,6 @@ const Sidebar = ({ case "connected": return "bg-green-500"; case "error": - return "bg-red-500"; case "error-connecting-to-proxy": return "bg-red-500"; default: @@ -767,10 +788,18 @@ const Sidebar = ({ case "connected": return "Connected"; case "error": { - const hasProxyToken = config.MCP_PROXY_AUTH_TOKEN?.value; - if (!hasProxyToken) { + if (mcpError && typeof mcpError.code === "number") { + return `MCP error ${mcpError.code}: ${mcpErrorDisplayMessage}`; + } + + if (mcpError) { + return `MCP error: ${mcpErrorDisplayMessage}`; + } + + if (shouldShowProxyTokenHint) { return "Connection Error - Did you add the proxy session token in Configuration?"; } + return "Connection Error - Check if your MCP server is running and proxy token is correct"; } case "error-connecting-to-proxy": @@ -782,6 +811,53 @@ const Sidebar = ({ + {connectionStatus === "error" && mcpError && ( +
+ + Error details + +
+
+ MCP error + {typeof mcpError.code === "number" + ? ` ${mcpError.code}` + : ""} + : {mcpErrorDisplayMessage} +
+ + {protocolVersionDetails?.supportedProtocolVersions?.length ? ( +
+ Supported versions:{" "} + + {protocolVersionDetails.supportedProtocolVersions.join( + ", ", + )} + +
+ ) : null} + + {mcpError.code === -32602 ? ( +
+ The server returned an error for{" "} + initialize instead of + negotiating a compatible protocol version.{" "} + + Spec: version negotiation + +
+ ) : null} +
+
+ )} + {connectionStatus === "connected" && serverImplementation && (
diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 03e898ca9..ee3d1a2fe 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -1,10 +1,11 @@ -import { render, screen, fireEvent, act } from "@testing-library/react"; +import { render, screen, fireEvent, act, within } from "@testing-library/react"; import "@testing-library/jest-dom"; import { describe, it, beforeEach, jest } from "@jest/globals"; import Sidebar from "../Sidebar"; import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants"; import { InspectorConfig } from "@/lib/configurationTypes"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { McpError } from "@modelcontextprotocol/sdk/types.js"; // Mock theme hook jest.mock("../../lib/hooks/useTheme", () => ({ @@ -1046,4 +1047,69 @@ describe("Sidebar", () => { ); }); }); + + describe("Connection status errors", () => { + it("shows MCP error details and hides proxy token hint", () => { + const mcpError = new McpError( + -32602, + "Unsupported protocol version: 2025-11-25 - supported versions: 2025-06-18,2025-03-26,2024-11-05,2024-10-07", + ); + + renderSidebar({ + connectionStatus: "error", + connectionError: mcpError, + config: { + ...DEFAULT_INSPECTOR_CONFIG, + MCP_PROXY_AUTH_TOKEN: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, + value: "", + }, + }, + }); + + expect( + screen.getAllByText( + /MCP error -32602: Unsupported protocol version: 2025-11-25/i, + ).length, + ).toBeGreaterThan(0); + + expect( + within(screen.getByTestId("connection-error-details")).getAllByText( + /Supported versions:/i, + ).length, + ).toBeGreaterThan(0); + + const details = within(screen.getByTestId("connection-error-details")); + expect(details.getAllByText(/2025-06-18/).length).toBeGreaterThan(0); + expect(details.getAllByText(/2025-03-26/).length).toBeGreaterThan(0); + expect(details.getAllByText(/2024-11-05/).length).toBeGreaterThan(0); + expect(details.getAllByText(/2024-10-07/).length).toBeGreaterThan(0); + + expect( + screen.queryByText( + /Did you add the proxy session token in Configuration\?/i, + ), + ).not.toBeInTheDocument(); + }); + + it("does not show proxy token hint for direct connections", () => { + renderSidebar({ + connectionStatus: "error", + connectionType: "direct", + config: { + ...DEFAULT_INSPECTOR_CONFIG, + MCP_PROXY_AUTH_TOKEN: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, + value: "", + }, + }, + }); + + expect( + screen.queryByText( + /Did you add the proxy session token in Configuration\?/i, + ), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index eb782041b..127a0cdfc 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -112,6 +112,7 @@ export function useConnection({ }: UseConnectionOptions) { const [connectionStatus, setConnectionStatus] = useState("disconnected"); + const [connectionError, setConnectionError] = useState(null); const { toast } = useToast(); const [serverCapabilities, setServerCapabilities] = useState(null); @@ -411,6 +412,7 @@ export function useConnection({ }; const connect = async (_e?: unknown, retryCount: number = 0) => { + setConnectionError(null); const clientCapabilities = { capabilities: { sampling: {}, @@ -430,7 +432,8 @@ export function useConnection({ if (connectionType === "proxy") { try { await checkProxyHealth(); - } catch { + } catch (error) { + setConnectionError(error); setConnectionStatus("error-connecting-to-proxy"); return; } @@ -756,6 +759,7 @@ export function useConnection({ "Please enter the session token from the proxy server console in the Configuration settings.", variant: "destructive", }); + setConnectionError(error); setConnectionStatus("error"); return; } @@ -826,6 +830,7 @@ export function useConnection({ }); } console.error(e); + setConnectionError(e); setConnectionStatus("error"); } }; @@ -841,6 +846,7 @@ export function useConnection({ setMcpClient(null); setClientTransport(null); setConnectionStatus("disconnected"); + setConnectionError(null); setCompletionsSupported(false); setServerCapabilities(null); setMcpSessionId(null); @@ -854,6 +860,7 @@ export function useConnection({ return { connectionStatus, + connectionError, serverCapabilities, serverImplementation, mcpClient, diff --git a/client/src/utils/__tests__/mcpErrorUtils.test.ts b/client/src/utils/__tests__/mcpErrorUtils.test.ts new file mode 100644 index 000000000..d54345814 --- /dev/null +++ b/client/src/utils/__tests__/mcpErrorUtils.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "@jest/globals"; +import { McpError } from "@modelcontextprotocol/sdk/types.js"; +import { + getMcpErrorInfo, + parseUnsupportedProtocolVersionError, +} from "../mcpErrorUtils"; + +describe("mcpErrorUtils", () => { + describe("parseUnsupportedProtocolVersionError", () => { + test("parses supported protocol versions", () => { + const details = parseUnsupportedProtocolVersionError( + "MCP error -32602: Unsupported protocol version: 2025-11-25 - supported versions: 2025-06-18,2025-03-26,2024-11-05,2024-10-07", + ); + + expect(details).toEqual({ + supportedProtocolVersions: [ + "2025-06-18", + "2025-03-26", + "2024-11-05", + "2024-10-07", + ], + }); + }); + + test("returns null when no relevant fields are present", () => { + expect(parseUnsupportedProtocolVersionError("Some other error")).toBe( + null, + ); + }); + }); + + describe("getMcpErrorInfo", () => { + test("extracts code/message/data from McpError", () => { + const error = new McpError(-32602, "Unsupported protocol version", { + foo: "bar", + }); + + expect(getMcpErrorInfo(error)).toEqual({ + code: -32602, + message: "MCP error -32602: Unsupported protocol version", + data: { foo: "bar" }, + }); + }); + + test("extracts message from Error", () => { + const error = new Error("Connection failed"); + expect(getMcpErrorInfo(error)).toBe(null); + }); + + test("extracts MCP error code from Error.message", () => { + const error = new Error( + "McpError: MCP error -32602: Unsupported protocol version", + ); + expect(getMcpErrorInfo(error)).toEqual({ + code: -32602, + message: "McpError: MCP error -32602: Unsupported protocol version", + }); + }); + + test("returns null for HTTP error-like objects", () => { + expect(getMcpErrorInfo({ code: 401, message: "Unauthorized" })).toBe( + null, + ); + expect(getMcpErrorInfo({ code: 403, message: "Forbidden" })).toBe(null); + }); + }); +}); diff --git a/client/src/utils/mcpErrorUtils.ts b/client/src/utils/mcpErrorUtils.ts new file mode 100644 index 000000000..a3de54a68 --- /dev/null +++ b/client/src/utils/mcpErrorUtils.ts @@ -0,0 +1,59 @@ +import { McpError } from "@modelcontextprotocol/sdk/types.js"; + +export const parseUnsupportedProtocolVersionError = ( + message: string, +): { supportedProtocolVersions: string[] } | null => { + const supportedMatch = message.match(/supported versions:\s*([^\n]+)/i); + const supportedProtocolVersions = supportedMatch + ? supportedMatch[1] + .split(/[,\s]+/) + .map((value) => value.trim()) + .filter(Boolean) + : undefined; + + if (!supportedProtocolVersions?.length) { + return null; + } + + return { + supportedProtocolVersions, + }; +}; + +export interface McpErrorInfo { + code?: number; + message: string; + data?: unknown; +} + +export const getMcpErrorInfo = (error: unknown): McpErrorInfo | null => { + if (error instanceof McpError) { + return { code: error.code, message: error.message, data: error.data }; + } + + if (error instanceof Error) { + const mcpCodeMatch = error.message.match(/\bMCP error\s+(-?\d+):/i); + const code = mcpCodeMatch ? Number(mcpCodeMatch[1]) : undefined; + if (!Number.isFinite(code)) { + return null; + } + return { code, message: error.message }; + } + + if (error && typeof error === "object") { + const maybeAny = error as Record; + const maybeCode = maybeAny["code"]; + const maybeMessage = maybeAny["message"]; + const maybeData = maybeAny["data"]; + + if ( + typeof maybeMessage === "string" && + typeof maybeCode === "number" && + maybeCode < 0 + ) { + return { code: maybeCode, message: maybeMessage, data: maybeData }; + } + } + + return null; +};