diff --git a/README.md b/README.md index f1bd97ce..fb5992ec 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,37 @@ +# MCP Debug Inspector (Fork) + +This is a fork of the MCP Inspector with experimental support for the latest features: + +- **Streamable HTTP Protocol Support**: Full implementation of the Streamable HTTP transport protocol as specified in the MCP 2025-03-26 revision +- **Direct Connection Mode**: Connect directly to MCP servers without proxy intermediation for lower latency and real-world client simulation +- **Enhanced Debugging**: Improved error handling and diagnostic information for HTTP transport development + +## How to Use the New Features + +### Streamable HTTP + +The inspector now fully supports the Streamable HTTP protocol. To use it: + +1. Select "Streamable HTTP" from the transport type dropdown +2. Enter the URL of your MCP server (ensure the path ends with `/mcp`) +3. Click "Connect" + +### Direct Connection Mode + +For SSE and Streamable HTTP transports, you can now bypass the inspector's proxy server and connect directly to the MCP server: + +1. Select either "SSE" or "Streamable HTTP" from the transport type dropdown +2. Check the "Direct connection (no proxy)" checkbox +3. Enter the URL of your MCP server +4. Click "Connect" + +Direct connection mode provides: +- Lower latency - no proxy intermediation +- More realistic client behavior - connecting directly as a browser client would +- Better testing of actual CORS configurations + +Note that some debugging capabilities (like request/response inspection at the proxy level) are not available in direct mode. + # MCP Inspector The MCP inspector is a developer tool for testing and debugging MCP servers. @@ -11,36 +45,71 @@ The MCP inspector is a developer tool for testing and debugging MCP servers. To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`: ```bash -npx @modelcontextprotocol/inspector node build/index.js +npx @modelcontextprotocol/inspector-server node build/index.js ``` You can pass both arguments and environment variables to your MCP server. Arguments are passed directly to your server, while environment variables can be set using the `-e` flag: ```bash # Pass arguments only -npx @modelcontextprotocol/inspector build/index.js arg1 arg2 +npx @modelcontextprotocol/inspector-server build/index.js arg1 arg2 # Pass environment variables only -npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js +npx @modelcontextprotocol/inspector-server -e KEY=value -e KEY2=$VALUE2 node build/index.js # Pass both environment variables and arguments -npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js arg1 arg2 +npx @modelcontextprotocol/inspector-server -e KEY=value -e KEY2=$VALUE2 node build/index.js arg1 arg2 # Use -- to separate inspector flags from server arguments -npx @modelcontextprotocol/inspector -e KEY=$VALUE -- node build/index.js -e server-flag +npx @modelcontextprotocol/inspector-server -e KEY=$VALUE -- node build/index.js -e server-flag ``` The inspector runs both a client UI (default port 5173) and an MCP proxy server (default port 3000). Open the client UI in your browser to use the inspector. You can customize the ports if needed: ```bash -CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js +CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector-server node build/index.js ``` For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging). +## Architecture + +The MCP Inspector consists of three main components that communicate with each other: + +1. **Browser UI**: The web interface that shows requests, responses, and other debugging information. +2. **Inspector Server**: A backend proxy server that bridges between the browser UI and the actual MCP server. +3. **MCP Server**: The target server being debugged, which implements the MCP protocol. + +The communication flow works like this: + +``` +Browser UI <-> Inspector Server <-> MCP Server + (SSE) (Transport) +``` + +- The Browser UI always communicates with the Inspector Server using SSE (Server-Sent Events). +- The Inspector Server communicates with the MCP Server using one of three transport options: + - **STDIO**: Spawns the MCP Server as a subprocess and communicates via standard I/O. + - **SSE**: Connects to a remote MCP Server using Server-Sent Events protocol. + - **Streamable HTTP**: Connects to a remote MCP Server using the Streamable HTTP protocol. + +When you choose a transport type in the UI, it affects only how the Inspector Server communicates with the MCP Server, not how the Browser UI and Inspector Server communicate. + +## Supported Transport Types + +The inspector supports three transport methods to communicate with MCP servers: + +1. **Stdio**: Launches the MCP server as a subprocess and communicates via standard input/output. This is the most common transport for local development. + +2. **SSE (Server-Sent Events)**: Connects to a remote MCP server via SSE. This is useful for debugging cloud-hosted MCP servers. + +3. **Streamable HTTP**: Connects to an MCP server that implements the Streamable HTTP transport protocol as specified in MCP Protocol Revision 2025-03-26. This transport provides a more standardized HTTP-based communication method. + +You can select the transport type in the inspector's UI. + ### Authentication -The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header. +The inspector supports bearer token authentication for SSE and Streamable HTTP connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header. ### Security Considerations diff --git a/bin/cli.js b/bin/cli.js index 1b744ced..33ba5e53 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -64,7 +64,7 @@ async function main() { const CLIENT_PORT = process.env.CLIENT_PORT ?? "5173"; const SERVER_PORT = process.env.SERVER_PORT ?? "3000"; - console.log("Starting MCP inspector..."); + console.log("Starting MCP Debug inspector..."); const abort = new AbortController(); @@ -79,7 +79,7 @@ async function main() { [ inspectorServerPath, ...(command ? [`--env`, command] : []), - ...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []), + ...(mcpServerArgs.length > 0 ? [`--args=${mcpServerArgs.join(" ")}`] : []), ], { env: { @@ -102,7 +102,7 @@ async function main() { await Promise.any([server, client, delay(2 * 1000)]); const portParam = SERVER_PORT === "3000" ? "" : `?proxyPort=${SERVER_PORT}`; console.log( - `\nšŸ” MCP Inspector is up and running at http://127.0.0.1:${CLIENT_PORT}${portParam} šŸš€`, + `\nšŸ” MCP Debug Inspector is up and running at http://127.0.0.1:${CLIENT_PORT}${portParam} šŸš€`, ); try { diff --git a/client/package.json b/client/package.json index 9eff88c5..0f1ffc7d 100644 --- a/client/package.json +++ b/client/package.json @@ -1,11 +1,11 @@ { - "name": "@modelcontextprotocol/inspector-client", - "version": "0.7.0", - "description": "Client-side application for the Model Context Protocol inspector", + "name": "mcp-debug-client", + "version": "0.7.2", + "description": "Client-side application for the Model Context Protocol debug inspector", "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", + "author": "Anthropic, PBC (https://anthropic.com) and contributors", "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/inspector/issues", + "bugs": "https://github.com/QuantGeekDev/mcp-debug/issues", "type": "module", "bin": { "mcp-inspector-client": "./bin/cli.js" diff --git a/client/src/App.tsx b/client/src/App.tsx index 27078edf..8e7a5232 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -31,6 +31,7 @@ import { Hammer, Hash, MessageSquare, + BarChart, } from "lucide-react"; import { toast } from "react-toastify"; @@ -45,6 +46,7 @@ import RootsTab from "./components/RootsTab"; import SamplingTab, { PendingRequest } from "./components/SamplingTab"; import Sidebar from "./components/Sidebar"; import ToolsTab from "./components/ToolsTab"; +import StatsTab from "./components/StatsTab"; const params = new URLSearchParams(window.location.search); const PROXY_PORT = params.get("proxyPort") ?? "3000"; @@ -53,15 +55,12 @@ const PROXY_SERVER_URL = `http://${window.location.hostname}:${PROXY_PORT}`; const App = () => { // Handle OAuth callback route const [resources, setResources] = useState([]); - const [resourceTemplates, setResourceTemplates] = useState< - ResourceTemplate[] - >([]); + const [resourceTemplates, setResourceTemplates] = useState([]); const [resourceContent, setResourceContent] = useState(""); const [prompts, setPrompts] = useState([]); const [promptContent, setPromptContent] = useState(""); const [tools, setTools] = useState([]); - const [toolResult, setToolResult] = - useState(null); + const [toolResult, setToolResult] = useState(null); const [errors, setErrors] = useState>({ resources: null, prompts: null, @@ -77,21 +76,22 @@ const App = () => { const [sseUrl, setSseUrl] = useState(() => { return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse"; }); - const [transportType, setTransportType] = useState<"stdio" | "sse">(() => { + const [transportType, setTransportType] = useState<"stdio" | "sse" | "streamableHttp">(() => { return ( - (localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio" + (localStorage.getItem("lastTransportType") as "stdio" | "sse" | "streamableHttp") || "stdio" ); }); const [logLevel, setLogLevel] = useState("debug"); const [notifications, setNotifications] = useState([]); - const [stdErrNotifications, setStdErrNotifications] = useState< - StdErrNotification[] - >([]); + const [stdErrNotifications, setStdErrNotifications] = useState([]); const [roots, setRoots] = useState([]); const [env, setEnv] = useState>({}); const [bearerToken, setBearerToken] = useState(() => { return localStorage.getItem("lastBearerToken") || ""; }); + const [directConnection, setDirectConnection] = useState(() => { + return localStorage.getItem("lastDirectConnection") === "true" || false; + }); const [pendingSampleRequests, setPendingSampleRequests] = useState< Array< @@ -104,24 +104,14 @@ const App = () => { const nextRequestId = useRef(0); const rootsRef = useRef([]); - const [selectedResource, setSelectedResource] = useState( - null, - ); - const [resourceSubscriptions, setResourceSubscriptions] = useState< - Set - >(new Set()); + const [selectedResource, setSelectedResource] = useState(null); + const [resourceSubscriptions, setResourceSubscriptions] = useState>(new Set()); const [selectedPrompt, setSelectedPrompt] = useState(null); const [selectedTool, setSelectedTool] = useState(null); - const [nextResourceCursor, setNextResourceCursor] = useState< - string | undefined - >(); - const [nextResourceTemplateCursor, setNextResourceTemplateCursor] = useState< - string | undefined - >(); - const [nextPromptCursor, setNextPromptCursor] = useState< - string | undefined - >(); + const [nextResourceCursor, setNextResourceCursor] = useState(); + const [nextResourceTemplateCursor, setNextResourceTemplateCursor] = useState(); + const [nextPromptCursor, setNextPromptCursor] = useState(); const [nextToolCursor, setNextToolCursor] = useState(); const progressTokenRef = useRef(0); @@ -132,7 +122,7 @@ const App = () => { serverCapabilities, mcpClient, requestHistory, - makeRequest: makeConnectionRequest, + makeRequest, sendNotification, handleCompletion, completionsSupported, @@ -144,6 +134,7 @@ const App = () => { sseUrl, env, bearerToken, + directConnection, proxyServerUrl: PROXY_SERVER_URL, onNotification: (notification) => { setNotifications((prev) => [...prev, notification as ServerNotification]); @@ -157,7 +148,15 @@ const App = () => { onPendingRequest: (request, resolve, reject) => { setPendingSampleRequests((prev) => [ ...prev, - { id: nextRequestId.current++, request, resolve, reject }, + { + id: nextRequestId.current++, + request: request as unknown, + resolve: resolve as (result: CreateMessageResult) => void, + reject: reject as (error: Error) => void + } as PendingRequest & { + resolve: (result: CreateMessageResult) => void; + reject: (error: Error) => void; + }, ]); }, getRoots: () => rootsRef.current, @@ -183,19 +182,19 @@ const App = () => { localStorage.setItem("lastBearerToken", bearerToken); }, [bearerToken]); - // Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback) + useEffect(() => { + localStorage.setItem("lastDirectConnection", directConnection.toString()); + }, [directConnection]); + useEffect(() => { const serverUrl = params.get("serverUrl"); if (serverUrl) { setSseUrl(serverUrl); setTransportType("sse"); - // Remove serverUrl from URL without reloading the page const newUrl = new URL(window.location.href); newUrl.searchParams.delete("serverUrl"); window.history.replaceState({}, "", newUrl.toString()); - // Show success toast for OAuth toast.success("Successfully authenticated with OAuth"); - // Connect to the server connectMcpServer(); } }, [connectMcpServer]); @@ -204,7 +203,7 @@ const App = () => { fetch(`${PROXY_SERVER_URL}/config`) .then((response) => response.json()) .then((data) => { - setEnv(data.defaultEnvironment); + setEnv(data.defaultEnvironment || {}); if (data.defaultCommand) { setCommand(data.defaultCommand); } @@ -212,48 +211,72 @@ const App = () => { setArgs(data.defaultArgs); } }) - .catch((error) => - console.error("Error fetching default environment:", error), - ); + .catch((error) => { + console.error("Error fetching default environment:", error); + // Set default empty environment to prevent UI blocking + setEnv({}); + }); }, []); useEffect(() => { rootsRef.current = roots; }, [roots]); + useEffect(() => { + console.log(`[App] Connection status changed to: ${connectionStatus}`); + console.log( + `[App] Connection details - status: ${connectionStatus}, serverCapabilities: ${!!serverCapabilities}, mcpClient: ${!!mcpClient}` + ); + + if (connectionStatus === "connected" && mcpClient && !serverCapabilities) { + console.log("[App] Connection is established, but missing capabilities"); + try { + // Only log capabilities here, don't attempt to set them + // as we don't have the setter in this component + const caps = mcpClient.getServerCapabilities(); + console.log("[App] Retrieved capabilities directly:", caps); + } catch (e) { + console.error("[App] Error retrieving capabilities:", e); + } + } + }, [connectionStatus, serverCapabilities, mcpClient]); + useEffect(() => { if (!window.location.hash) { window.location.hash = "resources"; } }, []); - const handleApproveSampling = (id: number, result: CreateMessageResult) => { - setPendingSampleRequests((prev) => { - const request = prev.find((r) => r.id === id); - request?.resolve(result); - return prev.filter((r) => r.id !== id); - }); + const clearError = (tabKey: keyof typeof errors) => { + setErrors((prev) => ({ ...prev, [tabKey]: null })); }; - const handleRejectSampling = (id: number) => { - setPendingSampleRequests((prev) => { - const request = prev.find((r) => r.id === id); - request?.reject(new Error("Sampling request rejected")); - return prev.filter((r) => r.id !== id); - }); + // Add sampling handlers + const handleApproveSampling = (id: number, result: CreateMessageResult) => { + const pendingRequest = pendingSampleRequests.find((req) => req.id === id); + if (pendingRequest) { + pendingRequest.resolve(result); + setPendingSampleRequests((prev) => prev.filter((req) => req.id !== id)); + } }; - const clearError = (tabKey: keyof typeof errors) => { - setErrors((prev) => ({ ...prev, [tabKey]: null })); + const handleRejectSampling = (id: number) => { + const pendingRequest = pendingSampleRequests.find((req) => req.id === id); + if (pendingRequest) { + pendingRequest.reject(new Error("Request rejected by user")); + setPendingSampleRequests((prev) => prev.filter((req) => req.id !== id)); + } }; - const makeRequest = async ( + // Properly wrap makeRequest to handle tab error management + const makeRequestWrapper = async ( request: ClientRequest, schema: T, tabKey?: keyof typeof errors, - ) => { + ): Promise> => { try { - const response = await makeConnectionRequest(request, schema); + // Pass an options object instead of directly passing tabKey + const response = await makeRequest(request, schema); if (tabKey !== undefined) { clearError(tabKey); } @@ -271,56 +294,52 @@ const App = () => { }; const listResources = async () => { - const response = await makeRequest( + const response = await makeRequestWrapper( { method: "resources/list" as const, params: nextResourceCursor ? { cursor: nextResourceCursor } : {}, }, ListResourcesResultSchema, - "resources", + "resources" ); setResources(resources.concat(response.resources ?? [])); setNextResourceCursor(response.nextCursor); }; const listResourceTemplates = async () => { - const response = await makeRequest( + const response = await makeRequestWrapper( { method: "resources/templates/list" as const, - params: nextResourceTemplateCursor - ? { cursor: nextResourceTemplateCursor } - : {}, + params: nextResourceTemplateCursor ? { cursor: nextResourceTemplateCursor } : {}, }, ListResourceTemplatesResultSchema, - "resources", - ); - setResourceTemplates( - resourceTemplates.concat(response.resourceTemplates ?? []), + "resources" ); + setResourceTemplates(resourceTemplates.concat(response.resourceTemplates ?? [])); setNextResourceTemplateCursor(response.nextCursor); }; const readResource = async (uri: string) => { - const response = await makeRequest( + const response = await makeRequestWrapper( { method: "resources/read" as const, params: { uri }, }, ReadResourceResultSchema, - "resources", + "resources" ); setResourceContent(JSON.stringify(response, null, 2)); }; const subscribeToResource = async (uri: string) => { if (!resourceSubscriptions.has(uri)) { - await makeRequest( + await makeRequestWrapper( { method: "resources/subscribe" as const, params: { uri }, }, z.object({}), - "resources", + "resources" ); const clone = new Set(resourceSubscriptions); clone.add(uri); @@ -330,13 +349,13 @@ const App = () => { const unsubscribeFromResource = async (uri: string) => { if (resourceSubscriptions.has(uri)) { - await makeRequest( + await makeRequestWrapper( { method: "resources/unsubscribe" as const, params: { uri }, }, z.object({}), - "resources", + "resources" ); const clone = new Set(resourceSubscriptions); clone.delete(uri); @@ -345,45 +364,45 @@ const App = () => { }; const listPrompts = async () => { - const response = await makeRequest( + const response = await makeRequestWrapper( { method: "prompts/list" as const, params: nextPromptCursor ? { cursor: nextPromptCursor } : {}, }, ListPromptsResultSchema, - "prompts", + "prompts" ); setPrompts(response.prompts); setNextPromptCursor(response.nextCursor); }; const getPrompt = async (name: string, args: Record = {}) => { - const response = await makeRequest( + const response = await makeRequestWrapper( { method: "prompts/get" as const, params: { name, arguments: args }, }, GetPromptResultSchema, - "prompts", + "prompts" ); setPromptContent(JSON.stringify(response, null, 2)); }; const listTools = async () => { - const response = await makeRequest( + const response = await makeRequestWrapper( { method: "tools/list" as const, params: nextToolCursor ? { cursor: nextToolCursor } : {}, }, ListToolsResultSchema, - "tools", + "tools" ); setTools(response.tools); setNextToolCursor(response.nextCursor); }; const callTool = async (name: string, params: Record) => { - const response = await makeRequest( + const response = await makeRequestWrapper( { method: "tools/call" as const, params: { @@ -395,7 +414,7 @@ const App = () => { }, }, CompatibilityCallToolResultSchema, - "tools", + "tools" ); setToolResult(response); }; @@ -416,9 +435,7 @@ const App = () => { }; if (window.location.pathname === "/oauth/callback") { - const OAuthCallback = React.lazy( - () => import("./components/OAuthCallback"), - ); + const OAuthCallback = React.lazy(() => import("./components/OAuthCallback")); return ( Loading...}> @@ -442,6 +459,8 @@ const App = () => { setEnv={setEnv} bearerToken={bearerToken} setBearerToken={setBearerToken} + directConnection={directConnection} + setDirectConnection={setDirectConnection} onConnect={connectMcpServer} stdErrNotifications={stdErrNotifications} logLevel={logLevel} @@ -450,12 +469,10 @@ const App = () => { />
- {mcpClient ? ( + {connectionStatus === "connected" && serverCapabilities ? ( { onValueChange={(value) => (window.location.hash = value)} > - + Resources - + Prompts - + Tools @@ -507,6 +515,10 @@ const App = () => { Roots + + + Stats +
@@ -548,9 +560,7 @@ const App = () => { clearError("resources"); setSelectedResource(resource); }} - resourceSubscriptionsSupported={ - serverCapabilities?.resources?.subscribe || false - } + resourceSubscriptionsSupported={serverCapabilities?.resources?.subscribe || false} resourceSubscriptions={resourceSubscriptions} subscribeToResource={(uri) => { clearError("resources"); @@ -619,11 +629,11 @@ const App = () => { { - void makeRequest( + void makeRequestWrapper( { method: "ping" as const, }, - EmptyResultSchema, + EmptyResultSchema ); }} /> @@ -637,10 +647,27 @@ const App = () => { setRoots={setRoots} onRootsChange={handleRootsChange} /> + )}
+ ) : connectionStatus === "connected" && mcpClient ? ( +
+

+ Connected to MCP server but waiting for capabilities... +

+ +
) : (

diff --git a/client/src/components/SamplingTab.tsx b/client/src/components/SamplingTab.tsx index 21fc7dd8..cc21a269 100644 --- a/client/src/components/SamplingTab.tsx +++ b/client/src/components/SamplingTab.tsx @@ -14,11 +14,15 @@ export type PendingRequest = { export type Props = { pendingRequests: PendingRequest[]; - onApprove: (id: number, result: CreateMessageResult) => void; - onReject: (id: number) => void; + onApprove?: (id: number, result: CreateMessageResult) => void; + onReject?: (id: number) => void; }; -const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => { +const SamplingTab = ({ + pendingRequests, + onApprove = () => {}, + onReject = () => {} +}: Props) => { const handleApprove = (id: number) => { // For now, just return a stub response onApprove(id, { diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index ce4562c9..aa2b0bed 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -29,8 +29,8 @@ import { version } from "../../../package.json"; interface SidebarProps { connectionStatus: "disconnected" | "connected" | "error"; - transportType: "stdio" | "sse"; - setTransportType: (type: "stdio" | "sse") => void; + transportType: "stdio" | "sse" | "streamableHttp"; + setTransportType: (type: "stdio" | "sse" | "streamableHttp") => void; command: string; setCommand: (command: string) => void; args: string; @@ -41,6 +41,8 @@ interface SidebarProps { setEnv: (env: Record) => void; bearerToken: string; setBearerToken: (token: string) => void; + directConnection: boolean; + setDirectConnection: (direct: boolean) => void; onConnect: () => void; stdErrNotifications: StdErrNotification[]; logLevel: LoggingLevel; @@ -62,6 +64,8 @@ const Sidebar = ({ setEnv, bearerToken, setBearerToken, + directConnection, + setDirectConnection, onConnect, stdErrNotifications, logLevel, @@ -73,6 +77,15 @@ const Sidebar = ({ const [showBearerToken, setShowBearerToken] = useState(false); const [shownEnvVars, setShownEnvVars] = useState>(new Set()); + const handleTransportTypeChange = (type: "stdio" | "sse" | "streamableHttp") => { + setTransportType(type); + if (type === "streamableHttp" && !sseUrl.includes("/mcp")) { + const url = new URL(sseUrl || "http://localhost:3001"); + url.pathname = "/mcp"; + setSseUrl(url.toString()); + } + }; + return (

@@ -89,9 +102,7 @@ const Sidebar = ({ + {transportType === "streamableHttp" && ( +
+ For Streamable HTTP, use a URL with the MCP endpoint path. Example: https://example.com/mcp +
+ )}
+ {transportType !== "stdio" && ( +
+ setDirectConnection(e.target.checked)} + /> + +
+ +
+
+ )} + {transportType === "stdio" ? ( <>
@@ -129,11 +164,18 @@ const Sidebar = ({
setSseUrl(e.target.value)} className="font-mono" /> + {transportType === "streamableHttp" && !sseUrl.includes("/") && ( +
+ The URL should include a path (e.g., /mcp) for Streamable HTTP transport +
+ )}
+
+ + {logs.length === 0 ? ( +
No logs captured yet.
+ ) : ( +
+ {logs.map((log, index) => ( +
+
+ + {log.type} + {log.isSSE && ' (SSE)'} + +
{formatTime(log.timestamp)}
+
+ + {log.streamId && ( +
Stream: {log.streamId}
+ )} + + {log.id && ( +
ID: {String(log.id)}
+ )} + + {log.message && ( +
{log.message}
+ )} + + {(log.body || log.data) && ( + + + Show Content + +
+                          {formatJson(log.body || log.data)}
+                        
+
+
+
+ )} +
+ )).reverse()} +
+ )} + + + +
+

Spec Compliance Checks

+ + {specViolations.length > 0 ? ( +
+
+
Detected Violations
+
    + {specViolations.map((violation, index) => ( +
  • {violation}
  • + ))} +
+
+
+ ) : ( +
+

No spec violations detected.

+
+ )} + +
+
Spec Compliance Checklist
+
+
+
+ + Session Management +
+

+ {stats.sessionId + ? `Using session ID: ${stats.sessionId}` + : "Not using session management"} +

+
+ +
+
+ 0 ? "bg-green-500" : "bg-yellow-500" + }`}> + Server-Sent Events +
+

+ {stats.activeSSEConnections > 0 + ? `${stats.activeSSEConnections} active SSE connections` + : "No active SSE connections"} +

+
+ +
+
+ + Request-Response Handling +
+

+ {stats.pendingRequests === 0 + ? "All requests have received responses" + : `${stats.pendingRequests} pending requests without responses`} +

+
+ +
+
+ 0 ? "bg-green-500" : "bg-gray-300" + }`}> + Tool Call Flow +
+

+ {toolEvents.length > 0 + ? `${toolEvents.length} tool calls tracked` + : "No tool calls detected"} +

+
+
+
+
+
+ + ); +}; + +export default StreamableHttpStats; diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 710c80d1..42607088 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -23,6 +23,8 @@ describe("Sidebar Environment Variables", () => { setEnv: jest.fn(), bearerToken: "", setBearerToken: jest.fn(), + directConnection: false, + setDirectConnection: jest.fn(), onConnect: jest.fn(), stdErrNotifications: [], logLevel: "info" as const, diff --git a/client/src/components/ui/accordion.tsx b/client/src/components/ui/accordion.tsx new file mode 100644 index 00000000..cf69e326 --- /dev/null +++ b/client/src/components/ui/accordion.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/client/src/components/ui/badge.tsx b/client/src/components/ui/badge.tsx new file mode 100644 index 00000000..239baa67 --- /dev/null +++ b/client/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/client/src/lib/directTransports.ts b/client/src/lib/directTransports.ts new file mode 100644 index 00000000..61424c12 --- /dev/null +++ b/client/src/lib/directTransports.ts @@ -0,0 +1,999 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +interface Transport { + onmessage?: (message: JSONRPCMessage) => void; + onerror?: (error: Error) => void; + start(): Promise; + send(message: JSONRPCMessage): Promise; + close(): Promise; +} + +interface JSONRPCMessage { + jsonrpc: "2.0"; + id?: string | number; + method?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result?: any; + error?: { + code: number; + message: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?: any; + }; +} + +const JSONRPCMessageSchema = { + parse: (data: unknown): JSONRPCMessage => { + if (!data || typeof data !== 'object') { + throw new Error('Invalid JSON-RPC message'); + } + + if (data && typeof data === 'object' && 'jsonrpc' in data) { + return data as JSONRPCMessage; + } + + throw new Error('Invalid JSON-RPC message format'); + } +}; + +export class DirectTransportError extends Error { + readonly code: number; + readonly response?: Response; + + constructor(code: number, message: string, response?: Response) { + super(`Direct transport error: ${message}`); + this.code = code; + this.response = response; + this.name = "DirectTransportError"; + } +} + +interface ClientTransport extends Transport { + readonly isMCPClientTransport: boolean; +} + +abstract class DirectTransport implements Transport { + protected _url: URL; + protected _closed: boolean = false; + protected _headers: HeadersInit; + protected _abortController?: AbortController; + protected _useCredentials: boolean; + protected _sessionId?: string; + + constructor(url: URL, options?: { headers?: HeadersInit, useCredentials?: boolean }) { + this._url = url; + this._headers = options?.headers || {}; + this._useCredentials = options?.useCredentials !== undefined ? options.useCredentials : false; + } + + onmessage?: (message: JSONRPCMessage) => void; + onerror?: (error: Error) => void; + + abstract start(): Promise; + abstract send(message: JSONRPCMessage): Promise; + + async close(): Promise { + this._closed = true; + this._abortController?.abort(); + } +} + +// Define a structured log entry interface +interface TransportLogEntry { + type: 'request' | 'response' | 'error' | 'sseOpen' | 'sseClose' | 'sseMessage' | 'transport'; + timestamp: number; + streamId?: string; + message?: string; + body?: unknown; + data?: unknown; + id?: string; + isSSE?: boolean; + isRequest?: boolean; + reason?: string; + error?: boolean; + event?: string; + [key: string]: unknown; +} + +export class DirectSseTransport extends DirectTransport { + private _eventSource?: EventSource; + private _endpoint?: URL; + + async start(): Promise { + if (this._eventSource) { + throw new Error("DirectSseTransport already started"); + } + + return new Promise((resolve, reject) => { + const eventSource = new EventSource(this._url.toString(), { + withCredentials: this._useCredentials + }); + + eventSource.onopen = () => { + this._eventSource = eventSource; + resolve(); + }; + + eventSource.onerror = () => { + const error = new DirectTransportError( + 0, + "Failed to connect to SSE endpoint", + undefined + ); + reject(error); + this.onerror?.(error); + eventSource.close(); + }; + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + const message = JSONRPCMessageSchema.parse(data); + + if (message.result?.endpoint) { + this._endpoint = new URL(message.result.endpoint); + } + + if (message.result?.sessionId) { + this._sessionId = message.result.sessionId; + } + + this.onmessage?.(message); + } catch (error) { + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + } + }; + }); + } + + async send(message: JSONRPCMessage): Promise { + if (this._closed) { + throw new Error("Transport is closed"); + } + + if (!this._endpoint) { + throw new Error("Not connected or endpoint not received"); + } + + const headers = new Headers(this._headers); + headers.set("Content-Type", "application/json"); + + if (this._sessionId) { + headers.set("Mcp-Session-Id", this._sessionId); + } + + try { + const response = await fetch(this._endpoint.toString(), { + method: "POST", + headers, + body: JSON.stringify(message), + credentials: this._useCredentials ? "include" : "same-origin" + }); + + const sessionId = response.headers.get("Mcp-Session-Id"); + if (sessionId) { + this._sessionId = sessionId; + } + + if (!response.ok) { + const text = await response.text().catch(() => "Unknown error"); + throw new DirectTransportError(response.status, text, response); + } + } catch (error) { + if (error instanceof DirectTransportError) { + this.onerror?.(error); + throw error; + } + + const transportError = new DirectTransportError( + 0, + (error as Error).message || "Unknown error" + ); + this.onerror?.(transportError); + throw transportError; + } + } + + async close(): Promise { + this._eventSource?.close(); + this._eventSource = undefined; + + if (this._sessionId && this._endpoint) { + try { + const headers = new Headers(this._headers); + headers.set("Mcp-Session-Id", this._sessionId); + + await fetch(this._endpoint.toString(), { + method: "DELETE", + headers, + credentials: this._useCredentials ? "include" : "same-origin" + }).catch(() => { + }); + } catch { + // Ignore errors when terminating + } + } + + await super.close(); + } +} + +export class DirectStreamableHttpTransport extends DirectTransport implements ClientTransport { + readonly isMCPClientTransport: boolean = true; + private _lastEventId?: string; + private _activeStreams: Map> = new Map(); + private _pendingRequests: Map void, timestamp: number }> = new Map(); + private _hasEstablishedSession: boolean = false; + private _keepAliveInterval?: NodeJS.Timeout; + private _reconnectAttempts: number = 0; + private _reconnectTimeout?: NodeJS.Timeout; + private _logCallbacks: Array<(log: TransportLogEntry) => void> = []; + private _transportStats = { + sessionId: undefined as string | undefined, + lastRequestTime: 0, + lastResponseTime: 0, + requestCount: 0, + responseCount: 0, + sseConnectionCount: 0, + activeSSEConnections: 0, + receivedMessages: 0 + }; + + // Get the list of active stream IDs for UI display + getActiveStreams(): string[] { + return Array.from(this._activeStreams.keys()); + } + + // Register a callback to receive transport logs + registerLogCallback(callback: (log: TransportLogEntry) => void): void { + if (typeof callback === 'function') { + this._logCallbacks.push(callback); + } + } + + // Internal method to emit logs to all registered callbacks + private _emitLog(log: TransportLogEntry): void { + for (const callback of this._logCallbacks) { + try { + callback(log); + } catch (e) { + console.error("Error in log callback", e); + } + } + } + + private log(message: string, data?: unknown) { + const timestamp = new Date().toISOString(); + const prefix = `[StreamableHttp ${timestamp}]`; + if (data) { + console.log(`${prefix} ${message}`, data); + } else { + console.log(`${prefix} ${message}`); + } + } + + private logInit(step: number, message: string, data?: unknown) { + const timestamp = new Date().toISOString(); + const prefix = `[StreamableHttp INIT:${step} ${timestamp}]`; + console.group(prefix); + console.log(message); + if (data) { + console.log('Details:', data); + } + console.groupEnd(); + } + + getTransportStats() { + return { + ...this._transportStats, + activeSSEConnections: this._activeStreams.size, + pendingRequests: this._pendingRequests.size, + connectionEstablished: this._hasEstablishedSession + }; + } + + async start(): Promise { + this.log("Transport starting"); + this._startKeepAlive(); + return Promise.resolve(); + } + + private _startKeepAlive(): void { + if (this._keepAliveInterval) { + clearInterval(this._keepAliveInterval); + } + + // Send a ping every 30 seconds to keep the connection alive + this._keepAliveInterval = setInterval(() => { + if (this._hasEstablishedSession && this._sessionId) { + this.log("Sending keep-alive ping"); + // Send a ping notification + const pingMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "ping" + }; + + this.send(pingMessage).catch(error => { + this.log("Keep-alive ping failed", error); + // If ping fails, try to re-establish SSE connection + if (this._activeStreams.size === 0) { + this.log("Attempting to reconnect SSE after failed ping"); + this.listenForServerMessages().catch(() => { + this.log("Failed to reconnect SSE after ping failure"); + }); + } + }); + } + }, 30000); // 30 second interval + } + + private _debugMessage(message: JSONRPCMessage): void { + if ('result' in message && 'id' in message) { + if (message.result && typeof message.result === 'object' && 'protocolVersion' in message.result) { + console.log(`[DirectStreamableHttp] Received initialize response:`, message); + console.log(`[DirectStreamableHttp] Protocol version: ${message.result.protocolVersion}`); + console.log(`[DirectStreamableHttp] Server capabilities: ${JSON.stringify(message.result.capabilities, null, 2)}`); + + // Force update in debug console to help developers see the exact structure + console.table({ + 'protocol': message.result.protocolVersion, + 'hasPrompts': !!message.result.capabilities?.prompts, + 'hasResources': !!message.result.capabilities?.resources, + 'hasTools': !!message.result.capabilities?.tools, + 'hasLogging': !!message.result.capabilities?.logging + }); + } else { + console.log(`[DirectStreamableHttp] Received result for request ${message.id}`); + } + } else if ('method' in message) { + console.log(`[DirectStreamableHttp] Received method call/notification: ${message.method}`); + } else if ('error' in message) { + console.error(`[DirectStreamableHttp] Received error:`, message.error); + } + } + + async send(message: JSONRPCMessage): Promise { + if (this._closed) { + this.log("Cannot send message: transport is closed"); + throw new Error("Transport is closed"); + } + + const messages = Array.isArray(message) ? message : [message]; + const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg); + const isInitializeRequest = messages.some(msg => 'method' in msg && msg.method === 'initialize'); + const isInitializedNotification = messages.some(msg => 'method' in msg && msg.method === 'notifications/initialized'); + + this._transportStats.requestCount++; + this._transportStats.lastRequestTime = Date.now(); + + // Emit request log for UI + this._emitLog({ + type: 'request', + body: message, + timestamp: Date.now() + }); + + if (isInitializeRequest) { + this.logInit(1, "Step 1: Sending initialize request via HTTP POST", { + url: this._url.toString(), + method: "POST", + protocolVersion: messages.find(msg => 'method' in msg && msg.method === 'initialize')?.params?.protocolVersion || "unknown" + }); + this._sessionId = undefined; + this._hasEstablishedSession = false; + } else if (isInitializedNotification) { + this.logInit(3, "Step 3: Sending initialized notification with session ID", { + sessionId: this._sessionId + }); + } else if (this._hasEstablishedSession) { + // This is a normal request/response after initialization + this._logNormalRequest(message); + } + + for (const msg of messages) { + if ('id' in msg && 'method' in msg) { + this._pendingRequests.set(msg.id, { + resolve: () => {}, + timestamp: Date.now() + }); + } + } + + // Only abort previous requests if this isn't part of the initialization sequence + // This prevents aborting critical connection sequence messages + if (!isInitializeRequest && !isInitializedNotification) { + this._abortController?.abort(); + } + this._abortController = new AbortController(); + + const headers = new Headers(this._headers); + headers.set("Content-Type", "application/json"); + headers.set("Accept", "application/json, text/event-stream"); + + if (this._sessionId && !isInitializeRequest) { + headers.set("Mcp-Session-Id", this._sessionId); + this.log("Including session ID in request header", this._sessionId); + } else if (!isInitializeRequest) { + this.log("No session ID available for request"); + } + + try { + this.log("Sending fetch request", { + url: this._url.toString(), + method: "POST", + headers: Object.fromEntries(headers.entries()), + bodyPreview: JSON.stringify(message).substring(0, 100) + (JSON.stringify(message).length > 100 ? '...' : '') + }); + + const response = await fetch(this._url.toString(), { + method: "POST", + headers, + body: JSON.stringify(message), + signal: this._abortController.signal, + credentials: this._useCredentials ? "include" : "same-origin" + }); + + this._transportStats.responseCount++; + this._transportStats.lastResponseTime = Date.now(); + + const sessionId = response.headers.get("Mcp-Session-Id"); + if (sessionId) { + this.log("Received session ID in response header", sessionId); + this._transportStats.sessionId = sessionId; + + const hadNoSessionBefore = !this._sessionId; + this._sessionId = sessionId; + + if (isInitializeRequest && hadNoSessionBefore) { + this.logInit(2, "Step 2: Received initialize response with session ID", { + sessionId, + status: response.status, + contentType: response.headers.get("Content-Type") + }); + this._hasEstablishedSession = true; + + // Let the Client handle sending the initialized notification + // This will be done by the client.connect() flow after initialize response + } + } + + if (!response.ok) { + // Handle 404 per spec: if we get 404 with a session ID, the session has expired + if (response.status === 404 && this._sessionId) { + this.log("Session expired (404), retrying without session ID"); + this._sessionId = undefined; + this._hasEstablishedSession = false; + this._transportStats.sessionId = undefined; + // Try again without session ID + return this.send(message); + } + + const text = await response.text().catch(() => "Unknown error"); + this.log("Error response", { status: response.status, text }); + throw new DirectTransportError(response.status, text, response); + } + + const contentType = response.headers.get("Content-Type"); + this.log("Response received", { + status: response.status, + contentType, + responseSize: response.headers.get("Content-Length") || "unknown" + }); + + // Handle 202 Accepted per spec (for notifications/responses that don't need responses) + if (response.status === 202) { + this.log("202 Accepted response (no body)"); + return; + } else if (contentType?.includes("text/event-stream")) { + // Handle SSE response + this.log("SSE stream response initiated"); + await this.processStream(response, hasRequests); + } else if (contentType?.includes("application/json")) { + // Handle JSON response + const json = await response.json(); + + // Log the JSON response for UI + this._emitLog({ + type: 'response', + isSSE: false, + body: json, + timestamp: Date.now() + }); + + try { + // Special handling for initialize response + if (!Array.isArray(json) && + 'result' in json && + json.result && + typeof json.result === 'object' && + 'protocolVersion' in json.result) { + this.log("Processing initialization response with protocol version", json.result.protocolVersion); + + // Extra debug for init response + console.log("[DirectStreamableHttp] Full initialization response:", JSON.stringify(json, null, 2)); + } + + if (Array.isArray(json)) { + this.log("Processing JSON array response", { length: json.length }); + for (const item of json) { + const parsedMessage = JSONRPCMessageSchema.parse(item); + this._transportStats.receivedMessages++; + this._debugMessage(parsedMessage); + this.onmessage?.(parsedMessage); + + if ('id' in parsedMessage && parsedMessage.id != null && + ('result' in parsedMessage || 'error' in parsedMessage) && + this._pendingRequests.has(parsedMessage.id)) { + this.log("Clearing pending request", { id: parsedMessage.id }); + this._pendingRequests.delete(parsedMessage.id); + } + } + } else { + const parsedMessage = JSONRPCMessageSchema.parse(json); + this._transportStats.receivedMessages++; + this._debugMessage(parsedMessage); + + if ('result' in parsedMessage && parsedMessage.result && + typeof parsedMessage.result === 'object' && + 'sessionId' in parsedMessage.result) { + this._sessionId = String(parsedMessage.result.sessionId); + this._transportStats.sessionId = this._sessionId; + this.log("Set session ID from JSON result", this._sessionId); + } + + this.onmessage?.(parsedMessage); + + if ('id' in parsedMessage && parsedMessage.id != null && + ('result' in parsedMessage || 'error' in parsedMessage) && + this._pendingRequests.has(parsedMessage.id)) { + this.log("Clearing pending request", { id: parsedMessage.id }); + this._pendingRequests.delete(parsedMessage.id); + } + } + } catch (error) { + this.log("Error parsing JSON response", error); + this.onerror?.(error as Error); + } + } + } catch (error) { + this.log("Error during request", error); + + // Emit error log for UI + this._emitLog({ + type: 'error', + message: error instanceof Error ? error.message : String(error), + timestamp: Date.now() + }); + + if (error instanceof DirectTransportError) { + this.onerror?.(error); + throw error; + } + + const transportError = new DirectTransportError( + 0, + (error as Error).message || "Unknown error" + ); + this.onerror?.(transportError); + throw transportError; + } + + // Start listening for server messages if we've established a session + if (this._hasEstablishedSession && !this._activeStreams.size) { + // Don't auto-establish during the initialization sequence + if (!isInitializeRequest && !isInitializedNotification) { + this.log("Auto-establishing SSE connection after request completed"); + this.listenForServerMessages().catch(err => { + this.log("Failed to establish server message listener", err); + }); + } + } + } + + private async processStream(response: Response, hasRequests = false): Promise { + if (!response.body) { + this.log("Response body is null"); + throw new Error("Response body is null"); + } + + const reader = response.body.getReader(); + const streamId = Math.random().toString(36).substring(2, 15); + this._activeStreams.set(streamId, reader); + this._transportStats.sseConnectionCount++; + this._transportStats.activeSSEConnections = this._activeStreams.size; + + this.log("Processing SSE stream", { streamId, activeStreams: this._activeStreams.size }); + + // Emit stream open log for UI + this._emitLog({ + type: 'sseOpen', + streamId, + timestamp: Date.now(), + isRequest: hasRequests + }); + + const textDecoder = new TextDecoder(); + let buffer = ""; + let messageCount = 0; + let lastDataTime = Date.now(); + const maxIdleTime = 60000; // 60 seconds max idle time + + try { + while (true) { + // Check for excessive idle time - helps detect "hanging" connections + const currentTime = Date.now(); + if (currentTime - lastDataTime > maxIdleTime) { + this.log("Stream idle timeout exceeded", { streamId, idleTime: currentTime - lastDataTime }); + throw new Error("Stream idle timeout exceeded"); + } + + // Use an AbortController to handle potential network stalls + const readAbortController = new AbortController(); + const readTimeoutId = setTimeout(() => { + readAbortController.abort(); + }, 30000); // 30 second read timeout + + // Wrap the read in a Promise with our own AbortController + const readPromise = Promise.race([ + reader.read(), + new Promise((_, reject) => { + readAbortController.signal.addEventListener('abort', () => { + reject(new Error("Stream read timed out")); + }); + }) + ]); + + let readResult; + try { + readResult = await readPromise; + clearTimeout(readTimeoutId); + } catch (error) { + clearTimeout(readTimeoutId); + this.log("Read timeout or error", { streamId, error }); + throw error; // Rethrow to be caught by the outer try/catch + } + + const { done, value } = readResult as ReadableStreamReadResult; + + if (done) { + this.log("SSE stream completed", { streamId, messagesProcessed: messageCount }); + + // Emit stream close log for UI + this._emitLog({ + type: 'sseClose', + streamId, + reason: 'Stream completed normally', + timestamp: Date.now() + }); + + break; + } + + // Reset idle timer when we receive data + lastDataTime = Date.now(); + + const chunk = textDecoder.decode(value, { stream: true }); + this.log("SSE chunk received", { + streamId, + size: value.length, + preview: chunk.substring(0, 50).replace(/\n/g, "\\n") + (chunk.length > 50 ? '...' : '') + }); + + buffer += chunk; + + const events = buffer.split(/\n\n/); + buffer = events.pop() || ""; + + if (events.length > 0) { + this.log("SSE events found in buffer", { count: events.length }); + } + + for (const event of events) { + const lines = event.split(/\r\n|\r|\n/); + let currentData = ""; + let currentId = ""; + let eventType = "message"; + + for (const line of lines) { + if (line.startsWith("data:")) { + currentData += line.substring(5).trim(); + } else if (line.startsWith("id:")) { + currentId = line.substring(3).trim(); + } else if (line.startsWith("event:")) { + eventType = line.substring(6).trim(); + } + } + + if (eventType === "message" && currentData) { + messageCount++; + this.log("Processing SSE message", { + streamId, + eventType, + hasId: !!currentId, + dataPreview: currentData.substring(0, 50) + (currentData.length > 50 ? '...' : '') + }); + + try { + const parsedData = JSON.parse(currentData); + const message = JSONRPCMessageSchema.parse(parsedData); + this._transportStats.receivedMessages++; + this._debugMessage(message); + + // Emit SSE message log for UI + this._emitLog({ + type: 'sseMessage', + streamId, + data: message, + id: currentId, + timestamp: Date.now() + }); + + if (currentId) { + this._lastEventId = currentId; + this.log("Set last event ID", currentId); + } + + this.onmessage?.(message); + + if ('id' in message && message.id != null && + ('result' in message || 'error' in message) && + this._pendingRequests.has(message.id)) { + this.log("Clearing pending request from SSE", { id: message.id }); + this._pendingRequests.delete(message.id); + + if (hasRequests && this._pendingRequests.size === 0) { + this.log("All requests completed, cancelling SSE reader", { streamId }); + reader.cancel(); + break; + } + } + } catch (error) { + this.log("Error parsing SSE message", error); + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + } + } else if (event.trim()) { + this.log("Received SSE event without data or with non-message type", { + eventType, + content: event.substring(0, 100) + }); + } + } + } + } catch (error) { + this.log("Error in SSE stream processing", { streamId, error }); + + // Emit stream error log for UI + this._emitLog({ + type: 'sseClose', + streamId, + reason: error instanceof Error ? error.message : String(error), + error: true, + timestamp: Date.now() + }); + + if (!this._closed) { + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + } + } finally { + this._activeStreams.delete(streamId); + this._transportStats.activeSSEConnections = this._activeStreams.size; + this.log("SSE stream cleanup", { streamId, remainingStreams: this._activeStreams.size }); + } + } + + async listenForServerMessages(): Promise { + if (this._closed) { + this.log("Cannot listen for server messages: transport is closed"); + return; + } + + if (!this._sessionId) { + this.log("Cannot establish server-side listener without a session ID"); + throw new Error("Cannot establish server-side listener without a session ID"); + } + + if (this._activeStreams.size > 0) { + this.log("Server listener already active, skipping"); + return; + } + + const headers = new Headers(this._headers); + headers.set("Accept", "text/event-stream"); + headers.set("Mcp-Session-Id", this._sessionId); + + if (this._lastEventId) { + headers.set("Last-Event-ID", this._lastEventId); + this.log("Including Last-Event-ID in GET request", this._lastEventId); + } + + try { + this.logInit(4, "Step 4: Establishing SSE connection via HTTP GET", { + url: this._url.toString(), + sessionId: this._sessionId, + hasLastEventId: !!this._lastEventId + }); + + const response = await fetch(this._url.toString(), { + method: "GET", + headers, + credentials: this._useCredentials ? "include" : "same-origin" + }); + + if (!response.ok) { + if (response.status === 405) { + this.log("Server doesn't support GET method for server-initiated messages (405)"); + return; + } else if (response.status === 404 && this._sessionId) { + this.log("Session expired during GET request (404)"); + this._sessionId = undefined; + this._hasEstablishedSession = false; + this._transportStats.sessionId = undefined; + throw new Error("Session expired"); + } + + const text = await response.text().catch(() => "Unknown error"); + this.log("Error response from GET request", { status: response.status, text }); + throw new DirectTransportError(response.status, text, response); + } + + const contentType = response.headers.get("Content-Type"); + this.log("GET response received", { + status: response.status, + contentType + }); + + const sessionId = response.headers.get("Mcp-Session-Id"); + if (sessionId) { + this._sessionId = sessionId; + this._transportStats.sessionId = sessionId; + this.log("Updated session ID from GET response", sessionId); + } + + if (!contentType?.includes("text/event-stream")) { + this.log("WARNING: GET response is not SSE stream", { contentType }); + } + + this.log("Processing SSE stream from GET request"); + await this.processStream(response); + + // Connection closed successfully - reset reconnect attempts + this._reconnectAttempts = 0; + + if (!this._closed && this._sessionId) { + this.log("SSE stream closed normally, reconnecting immediately"); + this.listenForServerMessages().catch(() => { + this.log("Failed to reconnect to server messages"); + this._scheduleReconnect(); + }); + } + } catch (error) { + this.log("Error in listenForServerMessages", error); + + // Emit error log for UI + this._emitLog({ + type: 'error', + message: error instanceof Error ? error.message : String(error), + timestamp: Date.now() + }); + + if (!this._closed) { + this._scheduleReconnect(); + } + + if (error instanceof DirectTransportError) { + this.onerror?.(error); + throw error; + } + + const transportError = new DirectTransportError( + 0, + (error instanceof Error ? error.message : String(error)) || "Unknown error" + ); + this.onerror?.(transportError); + throw transportError; + } + } + + private _scheduleReconnect(): void { + if (this._reconnectTimeout) { + clearTimeout(this._reconnectTimeout); + } + + // Exponential backoff with jitter + // Start with 1 second, max out at ~30 seconds + const maxRetryDelayMs = 30000; + const baseDelayMs = 1000; + this._reconnectAttempts++; + + // Calculate delay with exponential backoff and some jitter + const exponentialDelay = Math.min( + maxRetryDelayMs, + baseDelayMs * Math.pow(1.5, Math.min(this._reconnectAttempts, 10)) + ); + const jitter = Math.random() * 0.3 * exponentialDelay; + const delayMs = exponentialDelay + jitter; + + this.log(`Scheduling reconnect attempt ${this._reconnectAttempts} in ${Math.round(delayMs)}ms`); + + this._reconnectTimeout = setTimeout(() => { + if (!this._closed && this._sessionId) { + this.log(`Reconnect attempt ${this._reconnectAttempts}`); + this.listenForServerMessages().catch(() => { + this.log(`Reconnect attempt ${this._reconnectAttempts} failed`); + this._scheduleReconnect(); + }); + } + }, delayMs); + } + + async close(): Promise { + this.log("Closing transport"); + this._closed = true; + + // Emit close notification + this._emitLog({ + type: 'transport', + event: 'closed', + timestamp: Date.now() + }); + + if (this._keepAliveInterval) { + clearInterval(this._keepAliveInterval); + this._keepAliveInterval = undefined; + } + + if (this._reconnectTimeout) { + clearTimeout(this._reconnectTimeout); + this._reconnectTimeout = undefined; + } + + for (const reader of this._activeStreams.values()) { + try { + this.log("Cancelling active stream reader"); + await reader.cancel(); + } catch { + // Ignore + } + } + this._activeStreams.clear(); + this._transportStats.activeSSEConnections = 0; + + if (this._sessionId) { + try { + const headers = new Headers(this._headers); + headers.set("Mcp-Session-Id", this._sessionId); + + this.log("Sending DELETE to terminate session", { sessionId: this._sessionId }); + await fetch(this._url.toString(), { + method: "DELETE", + headers, + credentials: this._useCredentials ? "include" : "same-origin" + }).catch(() => { + // Ignore errors when terminating session + }); + } catch { + // Ignore errors when terminating session + } + } + + this._logCallbacks = []; // Clear all log callbacks + + await super.close(); + this.log("Transport closed"); + } + + private _logNormalRequest(message: JSONRPCMessage) { + if (!this._hasEstablishedSession) return; + + // Only log the first few normal flow requests to avoid spam + const allRequests = this._transportStats.requestCount; + if (allRequests <= 10 || allRequests % 10 === 0) { + this.logInit(5, "Step 5: Normal request/response flow", { + method: 'method' in message ? message.method : 'response', + hasId: 'id' in message, + timestamp: new Date().toISOString() + }); + } + } +} diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index f0379eb7..34e4ad41 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -24,7 +24,7 @@ import { ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js"; -import { useState } from "react"; +import { useState, useRef } from "react"; import { toast } from "react-toastify"; import { z } from "zod"; import { SESSION_KEYS } from "../constants"; @@ -33,12 +33,23 @@ import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; import { authProvider } from "../auth"; import packageJson from "../../../package.json"; +import { + DirectSseTransport as RealDirectSseTransport, + DirectStreamableHttpTransport as RealDirectStreamableHttpTransport, + DirectTransportError as RealDirectTransportError +} from "../directTransports"; + +// Use the imported classes +const DirectSseTransport = RealDirectSseTransport; +const DirectStreamableHttpTransport = RealDirectStreamableHttpTransport; +const DirectTransportError = RealDirectTransportError; + const params = new URLSearchParams(window.location.search); const DEFAULT_REQUEST_TIMEOUT_MSEC = parseInt(params.get("timeout") ?? "") || 10000; interface UseConnectionOptions { - transportType: "stdio" | "sse"; + transportType: "stdio" | "sse" | "streamableHttp"; command: string; args: string; sseUrl: string; @@ -46,10 +57,11 @@ interface UseConnectionOptions { proxyServerUrl: string; bearerToken?: string; requestTimeout?: number; + directConnection?: boolean; onNotification?: (notification: Notification) => void; onStdErrNotification?: (notification: Notification) => void; - onPendingRequest?: (request: any, resolve: any, reject: any) => void; - getRoots?: () => any[]; + onPendingRequest?: (request: unknown, resolve: unknown, reject: unknown) => void; + getRoots?: () => unknown[]; } interface RequestOptions { @@ -58,6 +70,35 @@ interface RequestOptions { suppressToast?: boolean; } +async function testCORSWithServer(serverUrl: URL): Promise { + try { + console.log(`Testing CORS settings with server at ${serverUrl.toString()}`); + + console.log(`Sending preflight OPTIONS request to ${serverUrl.toString()}`); + const preflightResponse = await fetch(serverUrl.toString(), { + method: 'OPTIONS', + headers: { + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'Content-Type, Mcp-Session-Id', + 'Origin': window.location.origin + } + }); + + console.log(`CORS preflight response status: ${preflightResponse.status}`); + console.log(`CORS headers in preflight response:`, { + 'Access-Control-Allow-Origin': preflightResponse.headers.get('Access-Control-Allow-Origin'), + 'Access-Control-Allow-Methods': preflightResponse.headers.get('Access-Control-Allow-Methods'), + 'Access-Control-Allow-Headers': preflightResponse.headers.get('Access-Control-Allow-Headers'), + 'Access-Control-Expose-Headers': preflightResponse.headers.get('Access-Control-Expose-Headers') + }); + + return preflightResponse.ok; + } catch (error) { + console.error(`Error testing CORS settings: ${(error as Error).message}`); + return false; + } +} + export function useConnection({ transportType, command, @@ -67,6 +108,7 @@ export function useConnection({ proxyServerUrl, bearerToken, requestTimeout = DEFAULT_REQUEST_TIMEOUT_MSEC, + directConnection = false, onNotification, onStdErrNotification, onPendingRequest, @@ -82,6 +124,7 @@ export function useConnection({ { request: string; response?: string }[] >([]); const [completionsSupported, setCompletionsSupported] = useState(true); + const connectAttempts = useRef(0); const pushHistory = (request: object, response?: object) => { setRequestHistory((prev) => [ @@ -133,6 +176,15 @@ export function useConnection({ } }; + const makeConnectionRequest = async ( + request: ClientRequest, + schema: T, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _tabName?: string, // Ignored parameter for backward compatibility + ): Promise> => { + return makeRequest(request, schema, {}); + }; + const handleCompletion = async ( ref: ResourceReference | PromptReference, argName: string, @@ -161,14 +213,11 @@ export function useConnection({ }); return response?.completion.values || []; } catch (e: unknown) { - // Disable completions silently if the server doesn't support them. - // See https://github.com/modelcontextprotocol/specification/discussions/122 if (e instanceof McpError && e.code === ErrorCode.MethodNotFound) { setCompletionsSupported(false); return []; } - // Unexpected errors - show toast and rethrow toast.error(e instanceof Error ? e.message : String(e)); throw e; } @@ -183,11 +232,9 @@ export function useConnection({ try { await mcpClient.notification(notification); - // Log successful notifications pushHistory(notification); } catch (e: unknown) { if (e instanceof McpError) { - // Log MCP protocol errors pushHistory(notification, { error: e.message }); } toast.error(e instanceof Error ? e.message : String(e)); @@ -206,8 +253,18 @@ export function useConnection({ return false; }; + const setConnectionStatusWithLog = (status: "disconnected" | "connected" | "error") => { + console.log(`[Connection Status] Changing status from ${connectionStatus} to ${status}`); + setConnectionStatus(status); + }; + const connect = async (_e?: unknown, retryCount: number = 0) => { try { + setConnectionStatusWithLog("disconnected"); + connectAttempts.current++; + + console.log("Starting connection with transportType:", transportType, "directConnection:", directConnection); + const client = new Client( { name: "mcp-inspector", @@ -223,22 +280,156 @@ export function useConnection({ }, ); + if (directConnection && transportType !== "stdio") { + console.log(`Connecting directly to MCP server using ${transportType} transport`); + + const serverUrl = new URL(sseUrl); + + if (transportType === "streamableHttp" && !serverUrl.pathname.endsWith("/mcp")) { + if (serverUrl.pathname === "/" || !serverUrl.pathname) { + serverUrl.pathname = "/mcp"; + } + } + + const corsOk = await testCORSWithServer(serverUrl); + if (!corsOk) { + console.warn("CORS preflight test failed. Connection might still work, but be prepared for CORS errors."); + } + + const directHeaders: Record = {}; + if (bearerToken) { + directHeaders["Authorization"] = `Bearer ${bearerToken}`; + } + directHeaders["Content-Type"] = "application/json"; + + const origin = window.location.origin; + console.log(`Creating direct connection from origin: ${origin} to ${serverUrl.toString()}`); + + let clientTransport; + if (transportType === "sse") { + clientTransport = new DirectSseTransport(serverUrl, { + headers: directHeaders, + useCredentials: false + }); + } else if (transportType === "streamableHttp") { + clientTransport = new DirectStreamableHttpTransport(serverUrl, { + headers: directHeaders, + useCredentials: false + }); + } else { + throw new Error(`Unsupported transport type for direct connection: ${transportType}`); + } + + if (onNotification) { + client.setNotificationHandler(ProgressNotificationSchema, onNotification); + client.setNotificationHandler(ResourceUpdatedNotificationSchema, onNotification); + client.setNotificationHandler(LoggingMessageNotificationSchema, onNotification); + } + + if (onStdErrNotification) { + client.setNotificationHandler(StdErrNotificationSchema, onStdErrNotification); + } + + if (onPendingRequest) { + client.setRequestHandler(CreateMessageRequestSchema, (request) => { + return new Promise((resolve, reject) => { + onPendingRequest(request, resolve, reject); + }); + }); + } + + if (getRoots) { + client.setRequestHandler(ListRootsRequestSchema, async () => { + return { roots: getRoots() }; + }); + } + + console.log("Connecting to MCP server directly..."); + + try { + const transport = clientTransport; + + transport.onerror = (error) => { + console.error("Transport error:", error); + if (connectionStatus !== "connected" && + (error.message?.includes("session expired") || + error.message?.includes("connection closed") || + error.message?.includes("aborted"))) { + setConnectionStatusWithLog("error"); + toast.error(`Connection error: ${error.message}`); + } + }; + + console.log("Connecting to MCP server directly..."); + await client.connect(transport); + console.log("Connected directly to MCP server"); + + try { + const capabilities = client.getServerCapabilities(); + console.log("Server capabilities received:", capabilities); + + console.log("Updating connection state directly"); + + setMcpClient(() => client); + setServerCapabilities(() => capabilities ?? {} as ServerCapabilities); + setCompletionsSupported(() => true); + setConnectionStatusWithLog("connected"); + + console.log("Connection successful - UI should update now"); + + if (transportType === "streamableHttp") { + console.log("Attempting to start server message listener..."); + } + + return; + } catch (err) { + console.error("Error updating state:", err); + } + + return; + } catch (error) { + console.error("Failed to connect directly to MCP server:", error); + + if (error instanceof DirectTransportError) { + console.error("DirectTransportError details:", { + code: error.code, + message: error.message, + response: error.response ? { + status: error.response.status, + statusText: error.response.statusText, + } : undefined + }); + } + + const shouldRetry = await handleAuthError(error); + if (shouldRetry && retryCount < 3) { + return connect(undefined, retryCount + 1); + } + + throw error; + } + } + const backendUrl = new URL(`${proxyServerUrl}/sse`); backendUrl.searchParams.append("transportType", transportType); + if (transportType === "stdio") { backendUrl.searchParams.append("command", command); backendUrl.searchParams.append("args", args); backendUrl.searchParams.append("env", JSON.stringify(env)); } else { - backendUrl.searchParams.append("url", sseUrl); + const url = new URL(sseUrl); + + if (transportType === "streamableHttp" && !url.pathname) { + url.pathname = "/mcp"; + } + + backendUrl.searchParams.append("url", url.toString()); } - // Inject auth manually instead of using SSEClientTransport, because we're - // proxying through the inspector server first. const headers: HeadersInit = {}; - // Use manually provided bearer token if available, otherwise use OAuth tokens const token = bearerToken || (await authProvider.tokens())?.access_token; if (token) { headers["Authorization"] = `Bearer ${token}`; @@ -286,12 +477,11 @@ export function useConnection({ } catch (error) { console.error("Failed to connect to MCP server:", error); const shouldRetry = await handleAuthError(error); - if (shouldRetry) { + if (shouldRetry && retryCount < 3) { return connect(undefined, retryCount + 1); } if (error instanceof SseError && error.code === 401) { - // Don't set error state if we're about to redirect for auth return; } throw error; @@ -299,7 +489,7 @@ export function useConnection({ const capabilities = client.getServerCapabilities(); setServerCapabilities(capabilities ?? null); - setCompletionsSupported(true); // Reset completions support on new connection + setCompletionsSupported(true); if (onPendingRequest) { client.setRequestHandler(CreateMessageRequestSchema, (request) => { @@ -316,10 +506,18 @@ export function useConnection({ } setMcpClient(client); - setConnectionStatus("connected"); + setConnectionStatusWithLog("connected"); } catch (e) { - console.error(e); - setConnectionStatus("error"); + console.error("Connection error:", e); + setConnectionStatusWithLog("error"); + + if (retryCount < 2) { + setTimeout(() => { + connect(undefined, retryCount + 1); + }, 1000); + } else { + toast.error("Failed to connect to MCP server after multiple attempts"); + } } }; @@ -329,9 +527,11 @@ export function useConnection({ mcpClient, requestHistory, makeRequest, + makeConnectionRequest, sendNotification, handleCompletion, completionsSupported, connect, + setServerCapabilities, }; } diff --git a/package-lock.json b/package-lock.json index 448edd6e..d52a1d55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,38 +1,47 @@ { - "name": "@modelcontextprotocol/inspector", - "version": "0.7.0", + "name": "mcp-debug", + "version": "0.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@modelcontextprotocol/inspector", - "version": "0.7.0", + "name": "mcp-debug", + "version": "0.7.2", "license": "MIT", "workspaces": [ "client", "server" ], "dependencies": { - "@modelcontextprotocol/inspector-client": "^0.7.0", - "@modelcontextprotocol/inspector-server": "^0.7.0", + "@modelcontextprotocol/sdk": "^1.6.1", + "@radix-ui/react-accordion": "^1.2.3", "concurrently": "^9.0.1", + "cors": "^2.8.5", + "express": "^4.21.0", + "serve-handler": "^6.1.6", "shell-quote": "^1.8.2", "spawn-rx": "^5.1.2", - "ts-node": "^10.9.2" + "ts-node": "^10.9.2", + "uuid": "^11.1.0", + "ws": "^8.18.0", + "zod": "^3.23.8" }, "bin": { + "mcp-debug": "bin/cli.js", "mcp-inspector": "bin/cli.js" }, "devDependencies": { + "@types/content-type": "^1.1.8", "@types/jest": "^29.5.14", "@types/node": "^22.7.5", "@types/shell-quote": "^1.7.5", + "@types/uuid": "^10.0.0", "prettier": "3.3.3" } }, "client": { - "name": "@modelcontextprotocol/inspector-client", - "version": "0.7.0", + "name": "mcp-debug-client", + "version": "0.7.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.6.1", @@ -1909,14 +1918,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@modelcontextprotocol/inspector-client": { - "resolved": "client", - "link": true - }, - "node_modules/@modelcontextprotocol/inspector-server": { - "resolved": "server", - "link": true - }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.6.1.tgz", @@ -2354,6 +2355,119 @@ "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", "license": "MIT" }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.3.tgz", + "integrity": "sha512-RIQ15mrcvqIkDARJeERSuXSry2N8uYnxkdDetpfmalT/+0ntOXLkFOsh9iwlAsCv+qcmhZjbdJogIm6WBa6c4A==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collapsible": "1.1.3", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==" + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-collection": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", + "integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", @@ -2493,6 +2607,116 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.3.tgz", + "integrity": "sha512-jFSerheto1X03MUC0g6R7LedNW9EEGWdg9W1+MlpkMLwGkgkbUXLPBH/KIuWKXUoeYRVY11llqbTBDzuLg7qrw==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==" + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", @@ -4058,6 +4282,13 @@ "@types/node": "*" } }, + "node_modules/@types/content-type": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@types/content-type/-/content-type-1.1.8.tgz", + "integrity": "sha512-1tBhmVUeso3+ahfyaKluXe38p+94lovUZdoVfQ3OnJo9uJC42JT7CBoN3k9HYhAae+GwiBYmHu+N9FZhOG+2Pg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.17", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", @@ -4293,6 +4524,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true + }, "node_modules/@types/ws": { "version": "8.5.13", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", @@ -8266,6 +8503,14 @@ "node": ">= 0.4" } }, + "node_modules/mcp-debug-client": { + "resolved": "client", + "link": true + }, + "node_modules/mcp-debug-server": { + "resolved": "server", + "link": true + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -10746,6 +10991,18 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -11570,8 +11827,8 @@ } }, "server": { - "name": "@modelcontextprotocol/inspector-server", - "version": "0.7.0", + "name": "mcp-debug-server", + "version": "0.7.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.6.1", diff --git a/package.json b/package.json index 66cc1a00..dfdac202 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,15 @@ { - "name": "@modelcontextprotocol/inspector", + "name": "@modelcontextprotocol/inspector-server", "version": "0.7.0", - "description": "Model Context Protocol inspector", + "description": "Model Context Protocol inspector with enhanced HTTP streaming and direct connection support", "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", + "author": "Anthropic, PBC (https://anthropic.com) and contributors", "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/inspector/issues", + "bugs": "https://github.com/QuantGeekDev/mcp-debug/issues", "type": "module", "bin": { - "mcp-inspector": "./bin/cli.js" + "mcp-inspector": "./bin/cli.js", + "mcp-debug": "./bin/cli.js" }, "files": [ "bin", @@ -31,20 +32,28 @@ "start": "node ./bin/cli.js", "prepare": "npm run build", "prettier-fix": "prettier --write .", - "publish-all": "npm publish --workspaces --access public && npm publish --access public" + "publish-package": "npm run build && npm publish --access public" }, "dependencies": { - "@modelcontextprotocol/inspector-client": "^0.7.0", - "@modelcontextprotocol/inspector-server": "^0.7.0", + "@modelcontextprotocol/sdk": "^1.6.1", + "@radix-ui/react-accordion": "^1.2.3", "concurrently": "^9.0.1", + "cors": "^2.8.5", + "express": "^4.21.0", + "serve-handler": "^6.1.6", "shell-quote": "^1.8.2", "spawn-rx": "^5.1.2", - "ts-node": "^10.9.2" + "ts-node": "^10.9.2", + "uuid": "^11.1.0", + "ws": "^8.18.0", + "zod": "^3.23.8" }, "devDependencies": { + "@types/content-type": "^1.1.8", "@types/jest": "^29.5.14", "@types/node": "^22.7.5", "@types/shell-quote": "^1.7.5", + "@types/uuid": "^10.0.0", "prettier": "3.3.3" } } diff --git a/server/package.json b/server/package.json index 732993f9..a84e87e5 100644 --- a/server/package.json +++ b/server/package.json @@ -1,11 +1,11 @@ { - "name": "@modelcontextprotocol/inspector-server", - "version": "0.7.0", - "description": "Server-side application for the Model Context Protocol inspector", + "name": "mcp-debug-server", + "version": "0.7.2", + "description": "Server-side application for the Model Context Protocol debug inspector", "license": "MIT", - "author": "Anthropic, PBC (https://anthropic.com)", + "author": "Anthropic, PBC (https://anthropic.com) and contributors", "homepage": "https://modelcontextprotocol.io", - "bugs": "https://github.com/modelcontextprotocol/inspector/issues", + "bugs": "https://github.com/QuantGeekDev/mcp-debug/issues", "type": "module", "bin": { "mcp-inspector-server": "build/index.js" diff --git a/server/src/index.ts b/server/src/index.ts index 2a4fe658..22c2352f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -16,6 +16,7 @@ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import express from "express"; import { findActualExecutable } from "spawn-rx"; import mcpProxy from "./mcpProxy.js"; +import { StreamableHttpClientTransport, StreamableHttpError } from "./streamableHttpTransport.js"; const SSE_HEADERS_PASSTHROUGH = ["authorization"]; @@ -92,6 +93,30 @@ const createTransport = async (req: express.Request) => { console.log("Connected to SSE transport"); return transport; + } else if (transportType === "streamableHttp") { + const url = query.url as string; + const headers: HeadersInit = { + Accept: "application/json, text/event-stream", + }; + for (const key of SSE_HEADERS_PASSTHROUGH) { + if (req.headers[key] === undefined) { + continue; + } + + const value = req.headers[key]; + headers[key] = Array.isArray(value) ? value[value.length - 1] : value; + } + + console.log(`Streamable HTTP transport: url=${url}, headers=${Object.keys(headers)}`); + + const transport = new StreamableHttpClientTransport(new URL(url), { + headers, + }); + + await transport.start(); + + console.log("Connected to Streamable HTTP transport"); + return transport; } else { console.error(`Invalid transport type: ${transportType}`); throw new Error("Invalid transport type specified"); @@ -100,31 +125,37 @@ const createTransport = async (req: express.Request) => { app.get("/sse", async (req, res) => { try { - console.log("New SSE connection"); + console.log("New browser-inspector SSE connection"); let backingServerTransport; try { backingServerTransport = await createTransport(req); } catch (error) { - if (error instanceof SseError && error.code === 401) { + if ((error instanceof SseError || error instanceof StreamableHttpError) && error.code === 401) { console.error( "Received 401 Unauthorized from MCP server:", error.message, ); - res.status(401).json(error); + res.status(401).json({ + jsonrpc: "2.0", + id: "auth_error", + error: { + code: -32001, + message: `Authentication failed: ${error.message}`, + } + }); return; } throw error; } - console.log("Connected MCP client to backing server transport"); + console.log("Inspector successfully connected to MCP server"); const webAppTransport = new SSEServerTransport("/message", res); - console.log("Created web app transport"); + console.log("Created browser-inspector transport channel"); webAppTransports.push(webAppTransport); - console.log("Created web app transport"); await webAppTransport.start(); @@ -145,10 +176,21 @@ app.get("/sse", async (req, res) => { transportToServer: backingServerTransport, }); - console.log("Set up MCP proxy"); + console.log("Set up MCP proxy between browser and server"); + + res.on("close", () => { + console.log("Browser-inspector connection closed by client"); + }); } catch (error) { - console.error("Error in /sse route:", error); - res.status(500).json(error); + console.error("Error in browser-inspector connection:", error); + res.status(500).json({ + jsonrpc: "2.0", + id: "error", + error: { + code: -32603, + message: `Internal error: ${error instanceof Error ? error.message : String(error)}`, + }, + }); } }); @@ -162,10 +204,18 @@ app.post("/message", async (req, res) => { 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); + res.status(500).json({ + jsonrpc: "2.0", + id: "error", + error: { + code: -32603, + message: `Internal error: ${error instanceof Error ? error.message : String(error)}`, + }, + }); } }); @@ -175,11 +225,31 @@ app.get("/config", (req, res) => { defaultEnvironment, defaultCommand: values.env, defaultArgs: values.args, + supportedTransports: ["stdio", "sse", "streamableHttp"] }); } catch (error) { console.error("Error in /config route:", error); - res.status(500).json(error); + res.status(500).json({ + jsonrpc: "2.0", + id: "error", + error: { + code: -32603, + message: `Internal error: ${error instanceof Error ? error.message : String(error)}`, + }, + }); + } +}); + +process.on('SIGINT', async () => { + console.log('Shutting down gracefully...'); + for (const transport of webAppTransports) { + try { + await transport.close(); + } catch (error) { + console.error('Error closing transport:', error); + } } + process.exit(0); }); const PORT = process.env.PORT || 3000; diff --git a/server/src/streamableHttpTransport.ts b/server/src/streamableHttpTransport.ts new file mode 100644 index 00000000..fcce0b5a --- /dev/null +++ b/server/src/streamableHttpTransport.ts @@ -0,0 +1,375 @@ +import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { JSONRPCMessage, JSONRPCMessageSchema } from "@modelcontextprotocol/sdk/types.js"; + +export class StreamableHttpError extends Error { + constructor( + public readonly code: number, + message: string, + public readonly response?: Response + ) { + super(`Streamable HTTP error: ${message}`); + } +} + +/** + * Client transport for Streamable HTTP: this connects to an MCP server + * that implements the Streamable HTTP protocol. + */ +export class StreamableHttpClientTransport implements Transport { + private _url: URL; + private _sessionId?: string; + private _headers: HeadersInit; + private _abortController?: AbortController; + private _sseConnections: Map> = new Map(); + private _lastEventId?: string; + private _closed: boolean = false; + private _pendingRequests: Map void, timestamp: number }> = new Map(); + private _hasEstablishedSession: boolean = false; + + constructor(url: URL, options?: { headers?: HeadersInit }) { + this._url = url; + this._headers = options?.headers || {}; + } + + async start(): Promise { + if (this._closed) { + throw new Error("Transport was closed and cannot be restarted"); + } + + if (this._sseConnections.size > 0) { + throw new Error("StreamableHttpClientTransport already started!"); + } + + return Promise.resolve(); + } + + private async _startServerListening(): Promise { + if (this._closed || !this._sessionId) { + return; + } + + try { + const connectionId = crypto.randomUUID(); + await this.openServerSentEventsListener(connectionId); + } catch (error) { + if (error instanceof StreamableHttpError && error.code === 405) { + return; + } + } + } + + async send(message: JSONRPCMessage | JSONRPCMessage[]): Promise { + if (this._closed) { + throw new Error("Transport is closed"); + } + + const messages = Array.isArray(message) ? message : [message]; + const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg); + + const isInitialize = messages.some(msg => + 'method' in msg && msg.method === 'initialize' + ); + + for (const msg of messages) { + if ('id' in msg && 'method' in msg) { + this._pendingRequests.set(msg.id, { + resolve: () => {}, + timestamp: Date.now() + }); + } + } + + this._abortController?.abort(); + this._abortController = new AbortController(); + + const headers = new Headers(this._headers); + headers.set("Content-Type", "application/json"); + headers.set("Accept", "application/json, text/event-stream"); + + if (this._sessionId) { + headers.set("Mcp-Session-Id", this._sessionId); + } + + try { + const response = await fetch(this._url.toString(), { + method: "POST", + headers, + body: JSON.stringify(message), + signal: this._abortController.signal, + }); + + const sessionId = response.headers.get("Mcp-Session-Id"); + if (sessionId) { + const hadNoSessionBefore = !this._sessionId; + this._sessionId = sessionId; + + if (hadNoSessionBefore && isInitialize) { + this._hasEstablishedSession = true; + + const initializedNotification: JSONRPCMessage = { + jsonrpc: "2.0", + method: "notifications/initialized" + }; + + this.send(initializedNotification).then(() => { + setTimeout(() => { + this._startServerListening(); + }, 100); + }).catch(error => { + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + }); + + } + } + + if (!response.ok) { + if (response.status === 404 && this._sessionId) { + this._sessionId = undefined; + this._hasEstablishedSession = false; + return this.send(message); + } + + const text = await response.text().catch(() => "Unknown error"); + throw new StreamableHttpError(response.status, text, response); + } + + const contentType = response.headers.get("Content-Type"); + + if (response.status === 202) { + return; + } else if (contentType?.includes("text/event-stream")) { + const connectionId = crypto.randomUUID(); + await this.processSSEStream(connectionId, response, hasRequests); + } else if (contentType?.includes("application/json")) { + const json = await response.json(); + + try { + if (Array.isArray(json)) { + for (const item of json) { + const parsedMessage = JSONRPCMessageSchema.parse(item); + this.onmessage?.(parsedMessage); + + if ('id' in parsedMessage && + ('result' in parsedMessage || 'error' in parsedMessage) && + this._pendingRequests.has(parsedMessage.id)) { + this._pendingRequests.delete(parsedMessage.id); + } + } + } else { + const parsedMessage = JSONRPCMessageSchema.parse(json); + this.onmessage?.(parsedMessage); + + if ('id' in parsedMessage && + ('result' in parsedMessage || 'error' in parsedMessage) && + this._pendingRequests.has(parsedMessage.id)) { + this._pendingRequests.delete(parsedMessage.id); + } + } + } catch (error) { + this.onerror?.(error as Error); + } + } + } catch (error) { + if (error instanceof StreamableHttpError) { + this.onerror?.(error); + throw error; + } + + const streamError = new StreamableHttpError( + 0, + (error as Error).message || "Unknown error" + ); + this.onerror?.(streamError); + throw streamError; + } + } + + private async processSSEStream(connectionId: string, response: Response, isRequestResponse: boolean = false): Promise { + if (!response.body) { + throw new Error("No response body available"); + } + + const reader = response.body.getReader(); + this._sseConnections.set(connectionId, reader); + + const decoder = new TextDecoder(); + let buffer = ""; + let responseIds = new Set(); + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + this._sseConnections.delete(connectionId); + break; + } + + buffer += decoder.decode(value, { stream: true }); + + const events = buffer.split("\n\n"); + buffer = events.pop() || ""; + + for (const event of events) { + const lines = event.split("\n"); + let eventType = "message"; + let data = ""; + let id = undefined; + + for (const line of lines) { + if (line.startsWith("event:")) { + eventType = line.slice(7).trim(); + } else if (line.startsWith("data:")) { + data = line.slice(5).trim(); + } else if (line.startsWith("id:")) { + id = line.slice(3).trim(); + this._lastEventId = id; + } + } + + if (eventType === "message" && data) { + try { + const jsonData = JSON.parse(data); + + if (Array.isArray(jsonData)) { + for (const item of jsonData) { + const message = JSONRPCMessageSchema.parse(item); + this.onmessage?.(message); + + if ('id' in message && + ('result' in message || 'error' in message) && + this._pendingRequests.has(message.id)) { + responseIds.add(message.id); + this._pendingRequests.delete(message.id); + } + } + } else { + const message = JSONRPCMessageSchema.parse(jsonData); + this.onmessage?.(message); + + if ('id' in message && + ('result' in message || 'error' in message) && + this._pendingRequests.has(message.id)) { + responseIds.add(message.id); + this._pendingRequests.delete(message.id); + } + } + } catch (error) { + this.onerror?.(error as Error); + } + } + } + + if (isRequestResponse && this._pendingRequests.size === 0) { + break; + } + } + } catch (error) { + this._sseConnections.delete(connectionId); + throw error; + } finally { + if (this._sseConnections.has(connectionId)) { + this._sseConnections.delete(connectionId); + } + } + } + + async openServerSentEventsListener(connectionId: string = crypto.randomUUID()): Promise { + if (this._closed) { + throw new Error("Transport is closed"); + } + + if (this._sseConnections.has(connectionId)) { + return; + } + + if (!this._sessionId) { + throw new Error("Cannot establish server-side listener without a session ID"); + } + + const headers = new Headers(this._headers); + headers.set("Accept", "text/event-stream"); + headers.set("Mcp-Session-Id", this._sessionId); + + if (this._lastEventId) { + headers.set("Last-Event-ID", this._lastEventId); + } + + try { + const response = await fetch(this._url.toString(), { + method: "GET", + headers, + }); + + if (!response.ok) { + if (response.status === 405) { + throw new StreamableHttpError(405, "Method Not Allowed", response); + } else if (response.status === 404 && this._sessionId) { + this._sessionId = undefined; + this._hasEstablishedSession = false; + throw new Error("Session expired"); + } + + const text = await response.text().catch(() => "Unknown error"); + throw new StreamableHttpError(response.status, text, response); + } + + const sessionId = response.headers.get("Mcp-Session-Id"); + if (sessionId) { + this._sessionId = sessionId; + } + + await this.processSSEStream(connectionId, response); + + if (!this._closed) { + this.openServerSentEventsListener().catch(() => { + }); + } + } catch (error) { + if (error instanceof StreamableHttpError) { + this.onerror?.(error); + throw error; + } + + const streamError = new StreamableHttpError( + 0, + (error as Error).message || "Unknown error" + ); + this.onerror?.(streamError); + throw streamError; + } + } + + async close(): Promise { + this._closed = true; + + for (const [id, reader] of this._sseConnections.entries()) { + try { + await reader.cancel(); + } catch (error) { + } + } + this._sseConnections.clear(); + + this._abortController?.abort(); + + if (this._sessionId) { + try { + const headers = new Headers(this._headers); + headers.set("Mcp-Session-Id", this._sessionId); + + await fetch(this._url.toString(), { + method: "DELETE", + headers, + }).catch(() => {}); + } catch (error) { + } + } + + this.onclose?.(); + } + + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; +}