From 2cb025a00b1610f8a11f1b38ee9e642204125d63 Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Thu, 27 Mar 2025 20:09:10 +0100 Subject: [PATCH 01/14] feat: add http streaming with json responses --- README.md | 37 +- client/src/App.tsx | 52 +- client/src/components/Sidebar.tsx | 54 +- .../src/components/__tests__/Sidebar.test.tsx | 2 + client/src/lib/directTransports.ts | 503 ++++++++++++++++++ client/src/lib/hooks/useConnection.ts | 185 ++++++- package-lock.json | 8 + package.json | 1 + server/src/index.ts | 92 +++- server/src/streamableHttpTransport.ts | 409 ++++++++++++++ 10 files changed, 1292 insertions(+), 51 deletions(-) create mode 100644 client/src/lib/directTransports.ts create mode 100644 server/src/streamableHttpTransport.ts diff --git a/README.md b/README.md index f1bd97ce..6ac7c699 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,44 @@ CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build 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/client/src/App.tsx b/client/src/App.tsx index 0b0a3e13..32ee6401 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -51,17 +51,23 @@ const PROXY_PORT = params.get("proxyPort") ?? "3000"; const PROXY_SERVER_URL = `http://${window.location.hostname}:${PROXY_PORT}`; const App = () => { - // Handle OAuth callback route if (window.location.pathname === "/oauth/callback") { - const OAuthCallback = React.lazy( - () => import("./components/OAuthCallback"), - ); - return ( - Loading...}> - - - ); + return ; } + + return ; +}; + +const OAuthCallbackRoute = () => { + const OAuthCallback = React.lazy(() => import("./components/OAuthCallback")); + return ( + Loading...}> + + + ); +}; + +const MainApp = () => { const [resources, setResources] = useState([]); const [resourceTemplates, setResourceTemplates] = useState< ResourceTemplate[] @@ -87,9 +93,9 @@ 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"); @@ -102,6 +108,9 @@ const App = () => { const [bearerToken, setBearerToken] = useState(() => { return localStorage.getItem("lastBearerToken") || ""; }); + const [directConnection, setDirectConnection] = useState(() => { + return localStorage.getItem("lastDirectConnection") === "true" || false; + }); const [pendingSampleRequests, setPendingSampleRequests] = useState< Array< @@ -170,6 +179,7 @@ const App = () => { sseUrl, env, bearerToken, + directConnection, proxyServerUrl: PROXY_SERVER_URL, onNotification: (notification) => { setNotifications((prev) => [...prev, notification as ServerNotification]); @@ -183,7 +193,15 @@ const App = () => { onPendingRequest: (request, resolve, reject) => { setPendingSampleRequests((prev) => [ ...prev, - { id: nextRequestId.current++, request, resolve, reject }, + { + id: nextRequestId.current++, + request: request as any, + 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, @@ -209,19 +227,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(); } }, []); @@ -441,6 +459,8 @@ const App = () => { setEnv={setEnv} bearerToken={bearerToken} setBearerToken={setBearerToken} + directConnection={directConnection} + setDirectConnection={setDirectConnection} onConnect={connectMcpServer} stdErrNotifications={stdErrNotifications} logLevel={logLevel} diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 4f60a77e..8266e2ed 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 +
+ )}
+
) : (

diff --git a/client/src/components/StatsTab.tsx b/client/src/components/StatsTab.tsx new file mode 100644 index 00000000..25a71ea7 --- /dev/null +++ b/client/src/components/StatsTab.tsx @@ -0,0 +1,679 @@ +import { TabsContent } from "@/components/ui/tabs"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import StreamableHttpStats from "./StreamableHttpStats"; +import React, { useState, useEffect, useRef } from "react"; +import { v4 as uuidv4 } from 'uuid'; + +// Event category for filtering +type EventCategory = "all" | "http" | "sse" | "errors"; + +interface StreamEvent { + id: string; + timestamp: string; + content: string; + type: "message" | "connection" | "error"; + streamId?: string; + direction: "incoming" | "outgoing"; + category: EventCategory | "all"; // The primary category this event belongs to +} + +// Define the structure for transport logs +interface TransportLogEntry { + type: string; + timestamp: number; + streamId?: string; + message?: string; + body?: unknown; + data?: unknown; + isSSE?: boolean; + isRequest?: boolean; + reason?: string; + error?: boolean; + [key: string]: unknown; +} + +interface TransportWithHandlers { + onmessage?: (message: unknown) => void; + onerror?: (error: Error) => void; + getActiveStreams?: () => string[]; + registerLogCallback?: (callback: (log: TransportLogEntry) => void) => void; + getTransportStats?: () => TransportStats; + [key: string]: unknown; +} + +interface TransportStats { + sessionId?: string; + lastRequestTime: number; + lastResponseTime: number; + requestCount: number; + responseCount: number; + sseConnectionCount: number; + activeSSEConnections: number; + receivedMessages: number; + pendingRequests: number; + connectionEstablished: boolean; +} + +interface ClientWithTransport { + _transport?: TransportWithHandlers; + [key: string]: unknown; +} + +interface StatsTabProps { + mcpClient: unknown; +} + +// Track connection sequence steps +interface ConnectionStep { + id: string; + completed: boolean; + timestamp: string | null; + description: string; +} + +const StatsTab: React.FC = ({ mcpClient }) => { + const [sseEvents, setSseEvents] = useState([]); + const [selectedCategory, setSelectedCategory] = useState("all"); + const [activeStreamCount, setActiveStreamCount] = useState(0); + const [hasActiveConnection, setHasActiveConnection] = useState(false); + const [transportStats, setTransportStats] = useState(null); + const logContainerRef = useRef(null); + + // Connection sequence tracking using unique IDs for each step + const [connectionSteps, setConnectionSteps] = useState([ + { id: 'step-1', completed: false, timestamp: null, description: "Client sends initialize request via HTTP POST" }, + { id: 'step-2', completed: false, timestamp: null, description: "Server responds with capabilities and session ID" }, + { id: 'step-3', completed: false, timestamp: null, description: "Client sends initialized notification" }, + { id: 'step-4', completed: false, timestamp: null, description: "Client establishes SSE connection via HTTP GET" }, + { id: 'step-5', completed: false, timestamp: null, description: "Normal request/response flow begins" } + ]); + + // Keep track of whether we've already processed certain types of messages + const processedMessages = useRef>(new Set()); + + // Use a ref to track completed steps for immediate validation + const completedStepsRef = useRef>(new Set()); + + // Get filtered events based on selected category + const filteredEvents = sseEvents.filter(event => { + if (selectedCategory === "all") return true; + if (selectedCategory === "errors") return event.type === "error"; + return event.category === selectedCategory; + }); + + // Event counts for each category + const eventCounts = { + all: sseEvents.length, + http: sseEvents.filter(e => e.category === "http").length, + sse: sseEvents.filter(e => e.category === "sse").length, + errors: sseEvents.filter(e => e.type === "error").length + }; + + // Format JSON content for display + const formatJsonContent = (content: string): React.ReactNode => { + try { + if (content.startsWith('{') || content.startsWith('[')) { + const json = JSON.parse(content); + return ( +

+            {JSON.stringify(json, null, 2)}
+          
+ ); + } + return content; + } catch { + // Parse error, return the raw content + return content; + } + }; + + // Update a specific connection step + const markStepCompleted = (stepId: string) => { + // Check if this step was already processed + if (processedMessages.current.has(stepId)) return; + + // Get the step number from the ID + const stepNumber = parseInt(stepId.split('-')[1]); + + // Validate sequence order - steps must happen in order + if (stepNumber > 1) { + // Check if the previous step is completed - check the ref, not the state + const previousStepId = `step-${stepNumber - 1}`; + const previousStepCompleted = completedStepsRef.current.has(previousStepId); + + if (!previousStepCompleted) { + // For initialization steps, auto-complete previous steps + if (stepNumber <= 3) { + // Mark all steps from 1 to the current one + for (let i = 1; i < stepNumber; i++) { + const prevId = `step-${i}`; + if (!completedStepsRef.current.has(prevId)) { + completedStepsRef.current.add(prevId); + processedMessages.current.add(prevId); + + // Update the state for UI + setConnectionSteps(prevState => + prevState.map(step => + step.id === prevId + ? { ...step, completed: true, timestamp: new Date().toISOString() } + : step + ) + ); + } + } + } else { + // For later steps, we want proper sequencing + return; + } + } + } + + // Mark the current step as completed in the ref + completedStepsRef.current.add(stepId); + processedMessages.current.add(stepId); + + setConnectionSteps(prev => + prev.map(step => + step.id === stepId + ? { ...step, completed: true, timestamp: new Date().toISOString() } + : step + ) + ); + }; + + // Reset connection steps when connection is lost + const resetConnectionSteps = () => { + processedMessages.current.clear(); + completedStepsRef.current.clear(); + setConnectionSteps(prev => + prev.map(step => ({ ...step, completed: false, timestamp: null })) + ); + }; + + // Initialize the connection steps based on existing state + const initializeFromExistingState = (client: ClientWithTransport) => { + try { + if (!client._transport) return; + + const stats = client._transport.getTransportStats?.(); + if (stats) { + setTransportStats(stats); + + // If we have any statistics, we must have done an initialize request + if (stats.requestCount > 0) { + markStepCompleted('step-1'); + } + + // If we have a session ID, we got a response with capabilities + if (stats.sessionId) { + markStepCompleted('step-2'); + } + + // If connectionEstablished is true, we sent the initialized notification + if (stats.connectionEstablished) { + markStepCompleted('step-3'); + } + + // If there are active SSE connections, step 4 is complete + if (stats.activeSSEConnections > 0) { + markStepCompleted('step-4'); + } + + // If we've received any messages beyond initialization, normal flow has begun + if (stats.receivedMessages > 1) { + markStepCompleted('step-5'); + } + } + + // If we detect that all required steps are complete in one batch, mark them all completed + if (stats?.connectionEstablished) { + // Make sure steps 1-3 are marked complete + ['step-1', 'step-2', 'step-3'].forEach(stepId => { + if (!completedStepsRef.current.has(stepId)) { + markStepCompleted(stepId); + } + }); + } + + // Try to find console logs for protocol stages + try { + // Scan for browser resources containing transport logs + //@ts-expect-error - This is a browser-specific API check + if (window.performance && window.performance.getEntries) { + const consoleLogs = performance.getEntries().filter( + entry => entry.entryType === 'resource' && + entry.name.includes('directTransports.ts') + ); + + if (consoleLogs.length > 0) { + markStepCompleted('step-1'); + markStepCompleted('step-2'); + markStepCompleted('step-3'); + } + } + } catch { + // Silently handle API access errors + } + + // Check if active streams exist + const streams = client._transport.getActiveStreams?.(); + if (streams && streams.length > 0) { + setActiveStreamCount(streams.length); + markStepCompleted('step-4'); + markStepCompleted('step-5'); + } + } catch { + // Error handling is silent in production + } + }; + + // Poll transport status + useEffect(() => { + if (!mcpClient) { + setHasActiveConnection(false); + resetConnectionSteps(); + return; + } + + try { + const client = mcpClient as ClientWithTransport; + if (!client || !client._transport) { + setHasActiveConnection(false); + resetConnectionSteps(); + return; + } + + // Poll for transport status and active streams + const checkTransport = () => { + // Get transport stats if available + if (client._transport?.getTransportStats) { + const stats = client._transport.getTransportStats(); + setTransportStats(stats); + setHasActiveConnection(stats.connectionEstablished); + + // Update steps based on stats + if (stats.connectionEstablished && !processedMessages.current.has('step-3')) { + markStepCompleted('step-1'); + markStepCompleted('step-2'); + markStepCompleted('step-3'); + } + } + + // Check active streams + if (client._transport?.getActiveStreams) { + const streams = client._transport.getActiveStreams(); + setActiveStreamCount(streams.length); + + if (streams.length > 0) { + markStepCompleted('step-4'); + markStepCompleted('step-5'); + } + } + }; + + // Do immediate check + checkTransport(); + + // Set up interval for checking + const interval = setInterval(checkTransport, 1000); + return () => clearInterval(interval); + } catch { + // Silent error handling in production + } + }, [mcpClient]); + + // Subscribe to real transport events + useEffect(() => { + if (!mcpClient) { + setHasActiveConnection(false); + resetConnectionSteps(); + return; + } + + const addEvent = ( + content: string, + type: "message" | "connection" | "error", + category: EventCategory, + streamId?: string, + direction: "incoming" | "outgoing" = "incoming" + ) => { + const now = new Date(); + setSseEvents(prev => { + const newEvent: StreamEvent = { + id: uuidv4(), + timestamp: now.toISOString(), + content, + type, + streamId, + direction, + category + }; + + // Keep max 200 events + const updatedEvents = [...prev, newEvent]; + if (updatedEvents.length > 200) { + return updatedEvents.slice(-200); + } + return updatedEvents; + }); + }; + + try { + const client = mcpClient as ClientWithTransport; + if (!client || !client._transport) { + setHasActiveConnection(false); + resetConnectionSteps(); + return; + } + + setHasActiveConnection(true); + + // Initialize from existing state + initializeFromExistingState(client); + + // Check if the transport has a way to register a log callback + if (client._transport.registerLogCallback && typeof client._transport.registerLogCallback === 'function') { + client._transport.registerLogCallback((log: TransportLogEntry) => { + if (!log) return; + + // Handle different types of log entries + if (log.streamId && log.type === 'sseMessage') { + // This is an SSE message received + addEvent( + typeof log.data === 'string' ? log.data : JSON.stringify(log.data), + "message", + "sse", + log.streamId + ); + + // If we get an SSE message, step 5 is completed + markStepCompleted('step-5'); + } else if (log.streamId && log.type === 'sseOpen') { + // New SSE stream opened + addEvent( + `SSE Stream opened: ${log.streamId}`, + "connection", + "sse", + log.streamId + ); + + // Mark step 4 as completed - SSE connection established + markStepCompleted('step-4'); + } else if (log.streamId && log.type === 'sseClose') { + // SSE stream closed + addEvent( + `SSE Stream closed: ${log.streamId}${log.reason ? ` (${log.reason})` : ''}`, + "connection", + "sse", + log.streamId + ); + } else if (log.type === 'error') { + // Error event + addEvent( + log.message || 'Unknown error', + "error", + "errors", + log.streamId + ); + } else if (log.type === 'request') { + // Outgoing request + const requestBody = typeof log.body === 'string' ? log.body : JSON.stringify(log.body); + addEvent( + requestBody, + "message", + "http", + log.streamId, + "outgoing" + ); + + // Track connection sequence steps based on request content + try { + const requestObj = typeof log.body === 'string' ? JSON.parse(log.body) : log.body; + if (requestObj) { + // Check if this is an initialize request + if ('method' in requestObj && requestObj.method === 'initialize') { + markStepCompleted('step-1'); + } + + // Check if this is an initialized notification + if ('method' in requestObj && requestObj.method === 'notifications/initialized') { + markStepCompleted('step-3'); + } + + // Regular request after initialization + if ('method' in requestObj && + requestObj.method !== 'initialize' && + requestObj.method !== 'notifications/initialized') { + markStepCompleted('step-5'); + } + } + } catch { + // Silent error handling for parsing + } + } else if (log.type === 'response' && !log.isSSE) { + // Regular HTTP response (not SSE) + const responseBody = typeof log.body === 'string' ? log.body : JSON.stringify(log.body); + addEvent( + responseBody, + "message", + "http", + log.streamId, + "incoming" + ); + + // Track connection sequence based on response content + try { + // Check if this is an initialize response with session ID + if (typeof responseBody === 'string' && + responseBody.includes('"protocolVersion"') && + responseBody.includes('"capabilities"')) { + markStepCompleted('step-2'); + } + } catch { + // Silent error handling for parsing + } + } + }); + } + } catch { + // Silent error handling in production + setHasActiveConnection(false); + resetConnectionSteps(); + } + }, [mcpClient]); + + // Auto-scroll to bottom when new events come in + useEffect(() => { + if (logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; + } + }, [filteredEvents]); // Now using filteredEvents to avoid scrolling when just changing tabs + + const formatTimestamp = (timestamp: string | null) => { + if (!timestamp) return ""; + try { + const date = new Date(timestamp); + return date.toLocaleTimeString(); + } catch { + // Silently handle any parsing errors + return ""; + } + }; + + // Display connection status and stats + const getConnectionStatusSummary = () => { + if (!transportStats) return "No connection data available"; + + const summary = [ + `Session ID: ${transportStats.sessionId || "None"}`, + `Connection established: ${transportStats.connectionEstablished ? "Yes" : "No"}`, + `Active SSE streams: ${transportStats.activeSSEConnections}`, + `Total requests: ${transportStats.requestCount}`, + `Total responses: ${transportStats.responseCount}` + ]; + + return summary.join(' ā€¢ '); + }; + + return ( + +
+

MCP Transport Inspector

+ +
+ {/* Left Column - Connection Stats and Sequence */} +
+ {/* Connection Statistics */} +
+

Connection Statistics

+
+ + + {transportStats && ( +
+

{getConnectionStatusSummary()}

+
+ )} +
+
+ + {/* Connection Sequence */} +
+

Connection Sequence

+
+

+ The Streamable HTTP transport follows this initialization sequence per spec: +

+
    + {connectionSteps.map((step) => ( +
  1. + + {step.completed ? "āœ…" : "ā—‹"} + + {step.id.split('-')[1]}. +
    + {step.description} + {step.timestamp && ( + + Completed at: {formatTimestamp(step.timestamp)} + + )} +
    +
  2. + ))} +
+
+
+
+ + {/* Right Column - Network Traffic */} +
+
+

+ Network Traffic + {activeStreamCount > 0 && ( + + ({activeStreamCount} active stream{activeStreamCount !== 1 ? 's' : ''}) + + )} +

+
+ + setSelectedCategory(value as EventCategory)} + > + + + All Events + {eventCounts.all > 0 && {eventCounts.all}} + + + HTTP Requests + {eventCounts.http > 0 && {eventCounts.http}} + + + SSE Events + {eventCounts.sse > 0 && {eventCounts.sse}} + + + Errors + {eventCounts.errors > 0 && {eventCounts.errors}} + + + +
+
+ {filteredEvents.length > 0 ? ( + filteredEvents.map(event => ( +
+ [{event.timestamp.split('T')[1].split('.')[0]}]{' '} + + {/* Category indicator */} + + {event.category === "http" ? "HTTP" : event.category === "sse" ? "SSE" : "ERR"} + {' '} + + {event.streamId && ( + [{event.streamId.substring(0, 6)}] + )} + {event.direction === "outgoing" && ( + ā–¶ + )} + {event.direction === "incoming" && ( + ā—€ + )} + {formatJsonContent(event.content)} +
+ )) + ) : hasActiveConnection ? ( +
+ {selectedCategory === "all" + ? "No events received yet. Waiting for activity..." + : selectedCategory === "http" + ? "No HTTP requests/responses captured yet." + : selectedCategory === "sse" + ? "No SSE events captured yet. SSE connections will appear here." + : "No errors recorded yet."} +
+ ) : ( +
No active connection. Connect to an MCP server to see events.
+ )} +
+
+
+ 0 ? "bg-green-500 animate-pulse" : "bg-gray-500"}`}> + {activeStreamCount > 0 ? 'Live' : 'Inactive'} +
+
+
+
+
+
+
+
+ ); +}; + +export default StatsTab; diff --git a/client/src/components/StreamableHttpStats.tsx b/client/src/components/StreamableHttpStats.tsx new file mode 100644 index 00000000..0855e39d --- /dev/null +++ b/client/src/components/StreamableHttpStats.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useState } from "react"; + +// Define the shape of the transport stats +interface TransportStats { + sessionId?: string; + lastRequestTime: number; + lastResponseTime: number; + requestCount: number; + responseCount: number; + sseConnectionCount: number; + activeSSEConnections: number; + receivedMessages: number; + pendingRequests: number; + connectionEstablished: boolean; +} + +interface TransportWithStats { + getTransportStats(): TransportStats; +} + +interface StreamableHttpStatsProps { + mcpClient: unknown; +} + +const StreamableHttpStats: React.FC = ({ mcpClient }) => { + const [stats, setStats] = useState(null); + + useEffect(() => { + const fetchStats = () => { + if (!mcpClient) return; + + try { + // Access private _transport property using type cast + const client = mcpClient as unknown as { _transport?: unknown }; + const transport = client._transport as unknown as TransportWithStats; + + if (transport && typeof transport.getTransportStats === 'function') { + const transportStats = transport.getTransportStats(); + setStats(transportStats); + } + } catch (error) { + console.error("Error fetching transport stats:", error); + } + }; + + fetchStats(); + + // Refresh stats every 2 seconds + const interval = setInterval(fetchStats, 2000); + + return () => clearInterval(interval); + }, [mcpClient]); + + if (!stats) { + return
No stats available
; + } + + const formatTime = (timestamp: number) => { + if (!timestamp) return "Never"; + const date = new Date(timestamp); + return date.toLocaleTimeString(); + }; + + const calcLatency = () => { + if (stats.lastRequestTime && stats.lastResponseTime) { + const latency = stats.lastResponseTime - stats.lastRequestTime; + return `${latency}ms`; + } + return "N/A"; + }; + + return ( +
+
+
Session ID:
+
{stats.sessionId || "None"}
+ +
Connection:
+
{stats.connectionEstablished ? "Established" : "Not established"}
+ +
Requests:
+
{stats.requestCount}
+ +
Responses:
+
{stats.responseCount}
+ +
Messages Received:
+
{stats.receivedMessages}
+ +
SSE Connections:
+
{stats.activeSSEConnections} active / {stats.sseConnectionCount} total
+ +
Pending Requests:
+
{stats.pendingRequests}
+ +
Last Request:
+
{formatTime(stats.lastRequestTime)}
+ +
Last Response:
+
{formatTime(stats.lastResponseTime)}
+ +
Last Latency:
+
{calcLatency()}
+
+ +
+ 0 ? "bg-green-500" : "bg-gray-500" + }`} + /> + {stats.activeSSEConnections > 0 ? "SSE Stream Active" : "No Active SSE Stream"} +
+
+ ); +}; + +export default StreamableHttpStats; 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/lib/directTransports.ts b/client/src/lib/directTransports.ts index 8b0b36d4..61424c12 100644 --- a/client/src/lib/directTransports.ts +++ b/client/src/lib/directTransports.ts @@ -1,29 +1,29 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any interface Transport { - onmessage?: (message: any) => void; + onmessage?: (message: JSONRPCMessage) => void; onerror?: (error: Error) => void; start(): Promise; - send(message: any): Promise; + send(message: JSONRPCMessage): Promise; close(): Promise; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any 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; }; } -// this is simplified but should be sufficient while we wait for official SDK const JSONRPCMessageSchema = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any parse: (data: unknown): JSONRPCMessage => { if (!data || typeof data !== 'object') { throw new Error('Invalid JSON-RPC message'); @@ -59,7 +59,7 @@ abstract class DirectTransport implements Transport { protected _headers: HeadersInit; protected _abortController?: AbortController; protected _useCredentials: boolean; - protected _sessionId?: string; // Define sessionId at the base class level + protected _sessionId?: string; constructor(url: URL, options?: { headers?: HeadersInit, useCredentials?: boolean }) { this._url = url; @@ -79,6 +79,23 @@ abstract class DirectTransport implements Transport { } } +// 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; @@ -118,7 +135,6 @@ export class DirectSseTransport extends DirectTransport { this._endpoint = new URL(message.result.endpoint); } - // Extract session ID if it's in the result if (message.result?.sessionId) { this._sessionId = message.result.sessionId; } @@ -192,8 +208,10 @@ export class DirectSseTransport extends DirectTransport { method: "DELETE", headers, credentials: this._useCredentials ? "include" : "same-origin" - }).catch(() => {}); + }).catch(() => { + }); } catch { + // Ignore errors when terminating } } @@ -206,19 +224,172 @@ export class DirectStreamableHttpTransport extends DirectTransport implements Cl 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) { @@ -229,7 +400,11 @@ export class DirectStreamableHttpTransport extends DirectTransport implements Cl } } - this._abortController?.abort(); + // 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); @@ -238,15 +413,18 @@ export class DirectStreamableHttpTransport extends DirectTransport implements Cl if (this._sessionId && !isInitializeRequest) { headers.set("Mcp-Session-Id", this._sessionId); - console.log("Including session ID in request:", this._sessionId); - } else { - console.log("No session ID available for request"); + this.log("Including session ID in request header", this._sessionId); + } else if (!isInitializeRequest) { + this.log("No session ID available for request"); } try { - console.log("Sending request to:", this._url.toString()); - console.log("With headers:", Object.fromEntries(headers.entries())); - console.log("Request body:", JSON.stringify(message, null, 2)); + 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", @@ -256,54 +434,112 @@ export class DirectStreamableHttpTransport extends DirectTransport implements Cl credentials: this._useCredentials ? "include" : "same-origin" }); + this._transportStats.responseCount++; + this._transportStats.lastResponseTime = Date.now(); + const sessionId = response.headers.get("Mcp-Session-Id"); if (sessionId) { - console.log("Received session ID:", 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"); - console.error("Error response:", response.status, text); + this.log("Error response", { status: response.status, text }); throw new DirectTransportError(response.status, text, response); } const contentType = response.headers.get("Content-Type"); - console.log("Response content type:", contentType); + 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(); - console.log("JSON response:", JSON.stringify(json, null, 2)); + + // 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); - console.log("Set session ID from JSON result:", this._sessionId); + this._transportStats.sessionId = this._sessionId; + this.log("Set session ID from JSON result", this._sessionId); } this.onmessage?.(parsedMessage); @@ -311,16 +547,25 @@ export class DirectStreamableHttpTransport extends DirectTransport implements Cl 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) { - console.error("Error parsing JSON response:", error); + this.log("Error parsing JSON response", error); this.onerror?.(error as Error); } } } catch (error) { - console.error("Error during request:", 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; @@ -334,101 +579,241 @@ export class DirectStreamableHttpTransport extends DirectTransport implements Cl throw transportError; } - if (this._sessionId && messages.some(msg => 'method' in msg && msg.method === 'initialize')) { - this.listenForServerMessages().catch(() => { - }); + // 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) { - const { done, value } = await reader.read(); + // 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; } - buffer += textDecoder.decode(value, { stream: true }); + // Reset idle timer when we receive data + lastDataTime = Date.now(); - const lines = buffer.split(/\r\n|\r|\n/); - buffer = lines.pop() || ""; + 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 ? '...' : '') + }); - let currentData = ""; - let currentId = ""; + buffer += chunk; - 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 === "") { - if (currentData) { - try { - const parsedData = JSON.parse(currentData); - const message = JSONRPCMessageSchema.parse(parsedData); - + 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.onmessage?.(message); - - currentData = ""; - 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 ('id' in message && message.id != null && - ('result' in message || 'error' in message) && - this._pendingRequests.has(message.id)) { - this._pendingRequests.delete(message.id); - - if (hasRequests && this._pendingRequests.size === 0) { - reader.cancel(); - break; - } + if (hasRequests && this._pendingRequests.size === 0) { + this.log("All requests completed, cancelling SSE reader", { streamId }); + reader.cancel(); + break; } - } catch (error) { - this.onerror?.(error instanceof Error ? error : new Error(String(error))); } + } 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, @@ -437,28 +822,65 @@ export class DirectStreamableHttpTransport extends DirectTransport implements Cl 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); - if (!this._closed) { + // 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; @@ -472,32 +894,106 @@ export class DirectStreamableHttpTransport extends DirectTransport implements Cl 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(() => {}); + }).catch(() => { + // Ignore errors when terminating session + }); } catch { - // Ignore + // 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 61cfa7d1..8b821f35 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -240,11 +240,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 { - setConnectionStatus("disconnected"); + setConnectionStatusWithLog("disconnected"); connectAttempts.current++; + console.log("Starting connection with transportType:", transportType, "directConnection:", directConnection); + const client = new Client( { name: "mcp-inspector", @@ -324,17 +331,48 @@ export function useConnection({ }); } + 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(clientTransport); + await client.connect(transport); console.log("Connected directly to MCP server"); - const capabilities = client.getServerCapabilities(); - setServerCapabilities(capabilities ?? null); - setCompletionsSupported(true); + 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); + } - setMcpClient(client); - setConnectionStatus("connected"); return; } catch (error) { console.error("Failed to connect directly to MCP server:", error); @@ -451,10 +489,10 @@ export function useConnection({ } setMcpClient(client); - setConnectionStatus("connected"); + setConnectionStatusWithLog("connected"); } catch (e) { console.error("Connection error:", e); - setConnectionStatus("error"); + setConnectionStatusWithLog("error"); if (retryCount < 2) { setTimeout(() => { @@ -476,5 +514,6 @@ export function useConnection({ handleCompletion, completionsSupported, connect, + setServerCapabilities, }; } diff --git a/package-lock.json b/package-lock.json index e6c4bb4b..d52a1d55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,26 +1,33 @@ { - "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": { @@ -28,12 +35,13 @@ "@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", @@ -1910,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", @@ -2355,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", @@ -2494,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", @@ -4301,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", @@ -8274,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", @@ -10754,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", @@ -11578,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 37eeb541..eb85d89b 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.6.1", + "@radix-ui/react-accordion": "^1.2.3", "concurrently": "^9.0.1", "cors": "^2.8.5", "express": "^4.21.0", @@ -43,6 +44,7 @@ "shell-quote": "^1.8.2", "spawn-rx": "^5.1.2", "ts-node": "^10.9.2", + "uuid": "^11.1.0", "ws": "^8.18.0", "zod": "^3.23.8" }, @@ -51,6 +53,7 @@ "@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/src/streamableHttpTransport.ts b/server/src/streamableHttpTransport.ts index 990680e9..fcce0b5a 100644 --- a/server/src/streamableHttpTransport.ts +++ b/server/src/streamableHttpTransport.ts @@ -24,7 +24,6 @@ export class StreamableHttpClientTransport implements Transport { private _lastEventId?: string; private _closed: boolean = false; private _pendingRequests: Map void, timestamp: number }> = new Map(); - private _connectionId: string = crypto.randomUUID(); private _hasEstablishedSession: boolean = false; constructor(url: URL, options?: { headers?: HeadersInit }) { @@ -41,8 +40,6 @@ export class StreamableHttpClientTransport implements Transport { throw new Error("StreamableHttpClientTransport already started!"); } - // Per Streamable HTTP spec: we don't establish SSE at beginning - // We'll wait for initialize request to get a session ID first return Promise.resolve(); } @@ -56,7 +53,6 @@ export class StreamableHttpClientTransport implements Transport { await this.openServerSentEventsListener(connectionId); } catch (error) { if (error instanceof StreamableHttpError && error.code === 405) { - // Server doesn't support GET for server-initiated messages (allowed by spec) return; } } @@ -70,7 +66,6 @@ export class StreamableHttpClientTransport implements Transport { const messages = Array.isArray(message) ? message : [message]; const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg); - // Check if this is an initialization request const isInitialize = messages.some(msg => 'method' in msg && msg.method === 'initialize' ); @@ -88,7 +83,6 @@ export class StreamableHttpClientTransport implements Transport { this._abortController = new AbortController(); const headers = new Headers(this._headers); - // Per spec: client MUST include Accept header with these values headers.set("Content-Type", "application/json"); headers.set("Accept", "application/json, text/event-stream"); @@ -98,36 +92,40 @@ export class StreamableHttpClientTransport implements Transport { try { const response = await fetch(this._url.toString(), { - method: "POST", // Per spec: client MUST use HTTP POST + method: "POST", headers, body: JSON.stringify(message), signal: this._abortController.signal, }); - // Per spec: Server MAY assign session ID during initialization const sessionId = response.headers.get("Mcp-Session-Id"); if (sessionId) { const hadNoSessionBefore = !this._sessionId; this._sessionId = sessionId; - // If this is the first time we've gotten a session ID and it's an initialize request - // then try to establish a server-side listener if (hadNoSessionBefore && isInitialize) { this._hasEstablishedSession = true; - // Start server listening after a short delay to ensure server has registered the session - setTimeout(() => { - this._startServerListening(); - }, 100); + + 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))); + }); + } } - // Handle response status if (!response.ok) { - // Per spec: if we get 404 with a session ID, the session has expired if (response.status === 404 && this._sessionId) { this._sessionId = undefined; this._hasEstablishedSession = false; - // Try again without session ID (per spec: client MUST start a new session) return this.send(message); } @@ -135,28 +133,22 @@ export class StreamableHttpClientTransport implements Transport { throw new StreamableHttpError(response.status, text, response); } - // Handle different response types based on content type const contentType = response.headers.get("Content-Type"); - // Per spec: 202 Accepted for responses/notifications that don't need responses if (response.status === 202) { return; } else if (contentType?.includes("text/event-stream")) { - // Per spec: server MAY return SSE stream for requests const connectionId = crypto.randomUUID(); await this.processSSEStream(connectionId, response, hasRequests); } else if (contentType?.includes("application/json")) { - // Per spec: server MAY return JSON for requests const json = await response.json(); try { if (Array.isArray(json)) { - // Handle batched responses for (const item of json) { const parsedMessage = JSONRPCMessageSchema.parse(item); this.onmessage?.(parsedMessage); - // Clear corresponding request from pending list if ('id' in parsedMessage && ('result' in parsedMessage || 'error' in parsedMessage) && this._pendingRequests.has(parsedMessage.id)) { @@ -164,11 +156,9 @@ export class StreamableHttpClientTransport implements Transport { } } } else { - // Handle single response const parsedMessage = JSONRPCMessageSchema.parse(json); this.onmessage?.(parsedMessage); - // Clear corresponding request from pending list if ('id' in parsedMessage && ('result' in parsedMessage || 'error' in parsedMessage) && this._pendingRequests.has(parsedMessage.id)) { @@ -217,9 +207,8 @@ export class StreamableHttpClientTransport implements Transport { buffer += decoder.decode(value, { stream: true }); - // Process complete events in buffer const events = buffer.split("\n\n"); - buffer = events.pop() || ""; // Keep the last incomplete event + buffer = events.pop() || ""; for (const event of events) { const lines = event.split("\n"); @@ -233,7 +222,6 @@ export class StreamableHttpClientTransport implements Transport { } else if (line.startsWith("data:")) { data = line.slice(5).trim(); } else if (line.startsWith("id:")) { - // Per spec: Save ID for resuming broken connections id = line.slice(3).trim(); this._lastEventId = id; } @@ -244,12 +232,10 @@ export class StreamableHttpClientTransport implements Transport { const jsonData = JSON.parse(data); if (Array.isArray(jsonData)) { - // Handle batched messages for (const item of jsonData) { const message = JSONRPCMessageSchema.parse(item); this.onmessage?.(message); - // Clear pending request if this is a response if ('id' in message && ('result' in message || 'error' in message) && this._pendingRequests.has(message.id)) { @@ -258,11 +244,9 @@ export class StreamableHttpClientTransport implements Transport { } } } else { - // Handle single message const message = JSONRPCMessageSchema.parse(jsonData); this.onmessage?.(message); - // Clear pending request if this is a response if ('id' in message && ('result' in message || 'error' in message) && this._pendingRequests.has(message.id)) { @@ -276,8 +260,6 @@ export class StreamableHttpClientTransport implements Transport { } } - // If this is a response stream and all requests have been responded to, - // we can close the connection if (isRequestResponse && this._pendingRequests.size === 0) { break; } @@ -301,24 +283,19 @@ export class StreamableHttpClientTransport implements Transport { return; } - // Per spec: Can't establish listener without session ID if (!this._sessionId) { throw new Error("Cannot establish server-side listener without a session ID"); } const headers = new Headers(this._headers); - // Per spec: Must include Accept: text/event-stream headers.set("Accept", "text/event-stream"); - // Per spec: Must include session ID if available headers.set("Mcp-Session-Id", this._sessionId); - // Per spec: Include Last-Event-ID for resuming broken connections if (this._lastEventId) { headers.set("Last-Event-ID", this._lastEventId); } try { - // Per spec: GET request to open an SSE stream const response = await fetch(this._url.toString(), { method: "GET", headers, @@ -326,10 +303,8 @@ export class StreamableHttpClientTransport implements Transport { if (!response.ok) { if (response.status === 405) { - // Per spec: Server MAY NOT support GET throw new StreamableHttpError(405, "Method Not Allowed", response); } else if (response.status === 404 && this._sessionId) { - // Per spec: 404 means session expired this._sessionId = undefined; this._hasEstablishedSession = false; throw new Error("Session expired"); @@ -339,19 +314,15 @@ export class StreamableHttpClientTransport implements Transport { throw new StreamableHttpError(response.status, text, response); } - // Per spec: Check for updated session ID const sessionId = response.headers.get("Mcp-Session-Id"); if (sessionId) { this._sessionId = sessionId; } - // Process the SSE stream await this.processSSEStream(connectionId, response); - // Automatically reconnect if the connection is closed but transport is still active if (!this._closed) { this.openServerSentEventsListener().catch(() => { - // Error already logged by inner function - no need to handle again }); } } catch (error) { @@ -372,20 +343,16 @@ export class StreamableHttpClientTransport implements Transport { async close(): Promise { this._closed = true; - // Cancel all active SSE connections for (const [id, reader] of this._sseConnections.entries()) { try { await reader.cancel(); } catch (error) { - // Ignore errors during cleanup } } this._sseConnections.clear(); - // Cancel any in-flight requests this._abortController?.abort(); - // Per spec: Clients SHOULD send DELETE to terminate session if (this._sessionId) { try { const headers = new Headers(this._headers); @@ -396,7 +363,6 @@ export class StreamableHttpClientTransport implements Transport { headers, }).catch(() => {}); } catch (error) { - // Ignore errors during cleanup } } From 930d254462d7ea8249ef6518f29b68fb90cb68de Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Sat, 29 Mar 2025 18:03:49 +0100 Subject: [PATCH 09/14] feat: add stats page --- client/src/components/StreamableHttpStats.tsx | 615 ++++++++++++++++-- 1 file changed, 575 insertions(+), 40 deletions(-) diff --git a/client/src/components/StreamableHttpStats.tsx b/client/src/components/StreamableHttpStats.tsx index 0855e39d..cbcdcdaf 100644 --- a/client/src/components/StreamableHttpStats.tsx +++ b/client/src/components/StreamableHttpStats.tsx @@ -1,4 +1,8 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; // Define the shape of the transport stats interface TransportStats { @@ -14,8 +18,56 @@ interface TransportStats { connectionEstablished: boolean; } +// Interface for JSON-RPC message structure +interface JsonRpcMessage { + jsonrpc: string; + id?: string | number; + method?: string; + params?: Record; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; +} + +// Interface for enhanced transport events +interface TransportLogEntry { + type: string; + timestamp: number; + streamId?: string; + message?: string; + body?: JsonRpcMessage | Record; + data?: JsonRpcMessage | Record; + id?: string | number; + isSSE?: boolean; + isRequest?: boolean; + reason?: string; + error?: boolean; + event?: string; + statusCode?: number; + [key: string]: unknown; +} + +// Interface for tool tracking +interface ToolEvent { + id: string | number; + name: string; + requestTime: number; + responseTime?: number; + duration?: number; + viaSSE: boolean; + status: 'pending' | 'completed' | 'error'; + request: JsonRpcMessage | Record; + response?: JsonRpcMessage | Record; + error?: string; +} + interface TransportWithStats { getTransportStats(): TransportStats; + registerLogCallback?(callback: (log: TransportLogEntry) => void): void; + getActiveStreams?(): string[]; } interface StreamableHttpStatsProps { @@ -24,6 +76,125 @@ interface StreamableHttpStatsProps { const StreamableHttpStats: React.FC = ({ mcpClient }) => { const [stats, setStats] = useState(null); + const [logs, setLogs] = useState([]); + const [activeStreams, setActiveStreams] = useState([]); + const [toolEvents, setToolEvents] = useState([]); + const [httpStatus, setHttpStatus] = useState>({}); + const [specViolations, setSpecViolations] = useState([]); + const transportRef = useRef(null); + const logCallbackRegistered = useRef(false); + + // Function to identify tool-related requests in logs + const processToolLogs = (logs: TransportLogEntry[]) => { + const toolCalls: ToolEvent[] = []; + + // Find all tool call requests + const toolRequests = logs.filter(log => { + const body = log.body as Record | undefined; + return log.type === 'request' && + body && + typeof body === 'object' && + 'method' in body && + body.method === 'tools/call' && + 'id' in body; + }); + + // Process each tool request and find matching responses + toolRequests.forEach(request => { + const body = request.body as Record | undefined; + if (!body || !('id' in body) || !('params' in body)) return; + + const requestId = body.id as string | number; + const params = body.params as Record | undefined; + const toolName = params?.name as string || 'unknown'; + + // Find matching response from the logs + const responseLog = logs.find(log => { + const data = log.data as Record | undefined; + return (log.type === 'response' || log.type === 'sseMessage') && + data && + typeof data === 'object' && + 'id' in data && + data.id === requestId; + }); + + const responseData = responseLog?.data as Record | undefined; + + const toolEvent: ToolEvent = { + id: requestId, + name: toolName, + requestTime: request.timestamp, + responseTime: responseLog?.timestamp, + duration: responseLog ? responseLog.timestamp - request.timestamp : undefined, + viaSSE: responseLog?.type === 'sseMessage' || false, + status: responseLog + ? (responseData && 'error' in responseData ? 'error' : 'completed') + : 'pending', + request: body, + response: responseData, + error: responseData && 'error' in responseData + ? JSON.stringify(responseData.error) + : undefined + }; + + toolCalls.push(toolEvent); + }); + + return toolCalls; + }; + + // Function to check for spec violations + const checkSpecViolations = (logs: TransportLogEntry[], stats: TransportStats) => { + const violations: string[] = []; + + // Check for HTTP status codes that might indicate spec violations + if (httpStatus['404'] && httpStatus['404'] > 0) { + if (stats.sessionId) { + violations.push("Session expired or not recognized (HTTP 404) while using a valid session ID"); + } + } + + if (httpStatus['405'] && httpStatus['405'] > 0) { + violations.push("Server returned HTTP 405 - Method Not Allowed. Server must support both GET and POST methods."); + } + + // Check for notification responses that aren't 202 Accepted + const notificationLogs = logs.filter(log => { + const body = log.body as Record | undefined; + return log.type === 'request' && + body && + typeof body === 'object' && + 'method' in body && + !('id' in body); + }); + + notificationLogs.forEach(log => { + const relatedResponse = logs.find(l => + l.type === 'response' && + l.timestamp > log.timestamp && + l.timestamp - log.timestamp < 1000 + ); + + if (relatedResponse && relatedResponse.statusCode !== 202) { + violations.push(`Notification response had status ${relatedResponse.statusCode}, expected 202 Accepted`); + } + }); + + // Check for responses containing JSON-RPC errors + const errorResponseLogs = logs.filter(log => { + const data = log.data as Record | undefined; + return (log.type === 'response' || log.type === 'sseMessage') && + data && + typeof data === 'object' && + 'error' in data; + }); + + if (errorResponseLogs.length > 0) { + violations.push(`Found ${errorResponseLogs.length} JSON-RPC error responses`); + } + + return violations; + }; useEffect(() => { const fetchStats = () => { @@ -35,8 +206,45 @@ const StreamableHttpStats: React.FC = ({ mcpClient }) const transport = client._transport as unknown as TransportWithStats; if (transport && typeof transport.getTransportStats === 'function') { + transportRef.current = transport; const transportStats = transport.getTransportStats(); setStats(transportStats); + + // Get active streams if available + if (transport.getActiveStreams && typeof transport.getActiveStreams === 'function') { + setActiveStreams(transport.getActiveStreams()); + } + + // Register log callback if not already done + if (transport.registerLogCallback && typeof transport.registerLogCallback === 'function' && !logCallbackRegistered.current) { + transport.registerLogCallback((logEntry: TransportLogEntry) => { + setLogs(prevLogs => { + const newLogs = [...prevLogs, logEntry]; + + // Update tool events based on logs + const updatedToolEvents = processToolLogs(newLogs); + setToolEvents(updatedToolEvents); + + // Track HTTP status codes + if (logEntry.type === 'response' && logEntry.statusCode) { + setHttpStatus(prev => ({ + ...prev, + [logEntry.statusCode.toString()]: (prev[logEntry.statusCode.toString()] || 0) + 1 + })); + } + + // Check for spec violations with updated logs and stats + if (transportStats) { + const violations = checkSpecViolations(newLogs, transportStats); + setSpecViolations(violations); + } + + // Keep last 100 logs for memory efficiency + return newLogs.slice(-100); + }); + }); + logCallbackRegistered.current = true; + } } } catch (error) { console.error("Error fetching transport stats:", error); @@ -69,49 +277,376 @@ const StreamableHttpStats: React.FC = ({ mcpClient }) return "N/A"; }; + const formatJson = (data: unknown) => { + try { + return JSON.stringify(data, null, 2); + } catch (_) { + return 'Unable to format data'; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'pending': return 'bg-yellow-500'; + case 'completed': return 'bg-green-500'; + case 'error': return 'bg-red-500'; + default: return 'bg-gray-500'; + } + }; + return ( -
-
-
Session ID:
-
{stats.sessionId || "None"}
- -
Connection:
-
{stats.connectionEstablished ? "Established" : "Not established"}
- -
Requests:
-
{stats.requestCount}
- -
Responses:
-
{stats.responseCount}
- -
Messages Received:
-
{stats.receivedMessages}
- -
SSE Connections:
-
{stats.activeSSEConnections} active / {stats.sseConnectionCount} total
- -
Pending Requests:
-
{stats.pendingRequests}
+ + + Overview + + Tool Calls + {toolEvents.length} + + + SSE Streams + {activeStreams.length} + + + Event Logs + {logs.length} + + + Compliance + {specViolations.length > 0 && ( + {specViolations.length} + )} + + + + +
+
Session ID:
+
{stats.sessionId || "None"}
+ +
Connection:
+
{stats.connectionEstablished ? "Established" : "Not established"}
+ +
Requests:
+
{stats.requestCount}
+ +
Responses:
+
{stats.responseCount}
+ +
Messages Received:
+
{stats.receivedMessages}
+ +
SSE Connections:
+
{stats.activeSSEConnections} active / {stats.sseConnectionCount} total
+ +
Pending Requests:
+
{stats.pendingRequests}
+ +
Last Request:
+
{formatTime(stats.lastRequestTime)}
+ +
Last Response:
+
{formatTime(stats.lastResponseTime)}
+ +
Last Latency:
+
{calcLatency()}
+
+ +
+ 0 ? "bg-green-500" : "bg-gray-500" + }`} + /> + {stats.activeSSEConnections > 0 ? "SSE Stream Active" : "No Active SSE Stream"} +
-
Last Request:
-
{formatTime(stats.lastRequestTime)}
+ {Object.keys(httpStatus).length > 0 && ( +
+

HTTP Status Codes

+
+ {Object.entries(httpStatus).map(([code, count]) => ( + +
HTTP {code}:
+
{count}
+
+ ))} +
+
+ )} +
+ + + {toolEvents.length === 0 ? ( +
No tool calls detected yet.
+ ) : ( +
+
+
Tool Name
+
Request Time
+
Duration
+
Transport
+
Status
+
+ + {toolEvents.map(tool => ( + + +
+ + {tool.name} + +
{formatTime(tool.requestTime)}
+
{tool.duration ? `${tool.duration}ms` : 'Pending'}
+
+ + {tool.viaSSE ? 'SSE' : 'HTTP JSON'} + +
+
+ + {tool.status.charAt(0).toUpperCase() + tool.status.slice(1)} + +
+
+ +
+
+

Request:

+
+                          {formatJson(tool.request)}
+                        
+
+ {tool.response && ( +
+

Response:

+
+                            {formatJson(tool.response)}
+                          
+
+ )} + {tool.error && ( +
+

Error:

+
+                            {tool.error}
+                          
+
+ )} +
+
+
+
+ ))} +
+ )} +
+ + + {activeStreams.length === 0 ? ( +
No active SSE streams.
+ ) : ( +
+

Active SSE Streams ({activeStreams.length})

+
+ {activeStreams.map(streamId => ( +
+
+
{streamId}
+ Active +
+ + {/* Show messages for this stream */} +
+
+ {logs.filter(log => log.streamId === streamId).length} messages +
+
+
+ ))} +
+
+ )} -
Last Response:
-
{formatTime(stats.lastResponseTime)}
+
+

Stream Events

+ {logs.filter(log => log.type === 'sseOpen' || log.type === 'sseClose').length === 0 ? ( +
No stream events detected yet.
+ ) : ( +
+ {logs + .filter(log => log.type === 'sseOpen' || log.type === 'sseClose') + .map((log, index) => ( +
+
+ + {log.type === 'sseOpen' ? 'Stream Opened' : 'Stream Closed'} + +
{formatTime(log.timestamp)}
+
+
+ {log.streamId &&
Stream ID: {log.streamId}
} + {log.reason &&
Reason: {log.reason}
} + {log.isRequest &&
Initiated by request: Yes
} +
+
+ )) + .reverse() + } +
+ )} +
+
+ + +
+

Transport Event Log

+ +
-
Last Latency:
-
{calcLatency()}
-
- -
- 0 ? "bg-green-500" : "bg-gray-500" - }`} - /> - {stats.activeSSEConnections > 0 ? "SSE Stream Active" : "No Active SSE Stream"} -
-
+ {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"} +

+
+
+
+
+
+ ); }; From 11d536ed7ad63d3d13f924ec2a6a6c5a43d039a9 Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Sat, 29 Mar 2025 18:07:48 +0100 Subject: [PATCH 10/14] fix: type errors in StreamableHttpStats.tsx --- client/src/components/StreamableHttpStats.tsx | 27 +++----------- client/src/components/ui/badge.tsx | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+), 23 deletions(-) create mode 100644 client/src/components/ui/badge.tsx diff --git a/client/src/components/StreamableHttpStats.tsx b/client/src/components/StreamableHttpStats.tsx index cbcdcdaf..7a90daca 100644 --- a/client/src/components/StreamableHttpStats.tsx +++ b/client/src/components/StreamableHttpStats.tsx @@ -4,7 +4,6 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; -// Define the shape of the transport stats interface TransportStats { sessionId?: string; lastRequestTime: number; @@ -18,7 +17,6 @@ interface TransportStats { connectionEstablished: boolean; } -// Interface for JSON-RPC message structure interface JsonRpcMessage { jsonrpc: string; id?: string | number; @@ -32,7 +30,6 @@ interface JsonRpcMessage { }; } -// Interface for enhanced transport events interface TransportLogEntry { type: string; timestamp: number; @@ -50,7 +47,6 @@ interface TransportLogEntry { [key: string]: unknown; } -// Interface for tool tracking interface ToolEvent { id: string | number; name: string; @@ -84,11 +80,9 @@ const StreamableHttpStats: React.FC = ({ mcpClient }) const transportRef = useRef(null); const logCallbackRegistered = useRef(false); - // Function to identify tool-related requests in logs const processToolLogs = (logs: TransportLogEntry[]) => { const toolCalls: ToolEvent[] = []; - // Find all tool call requests const toolRequests = logs.filter(log => { const body = log.body as Record | undefined; return log.type === 'request' && @@ -99,7 +93,6 @@ const StreamableHttpStats: React.FC = ({ mcpClient }) 'id' in body; }); - // Process each tool request and find matching responses toolRequests.forEach(request => { const body = request.body as Record | undefined; if (!body || !('id' in body) || !('params' in body)) return; @@ -108,7 +101,6 @@ const StreamableHttpStats: React.FC = ({ mcpClient }) const params = body.params as Record | undefined; const toolName = params?.name as string || 'unknown'; - // Find matching response from the logs const responseLog = logs.find(log => { const data = log.data as Record | undefined; return (log.type === 'response' || log.type === 'sseMessage') && @@ -143,11 +135,9 @@ const StreamableHttpStats: React.FC = ({ mcpClient }) return toolCalls; }; - // Function to check for spec violations const checkSpecViolations = (logs: TransportLogEntry[], stats: TransportStats) => { const violations: string[] = []; - // Check for HTTP status codes that might indicate spec violations if (httpStatus['404'] && httpStatus['404'] > 0) { if (stats.sessionId) { violations.push("Session expired or not recognized (HTTP 404) while using a valid session ID"); @@ -158,7 +148,6 @@ const StreamableHttpStats: React.FC = ({ mcpClient }) violations.push("Server returned HTTP 405 - Method Not Allowed. Server must support both GET and POST methods."); } - // Check for notification responses that aren't 202 Accepted const notificationLogs = logs.filter(log => { const body = log.body as Record | undefined; return log.type === 'request' && @@ -180,7 +169,6 @@ const StreamableHttpStats: React.FC = ({ mcpClient }) } }); - // Check for responses containing JSON-RPC errors const errorResponseLogs = logs.filter(log => { const data = log.data as Record | undefined; return (log.type === 'response' || log.type === 'sseMessage') && @@ -201,7 +189,6 @@ const StreamableHttpStats: React.FC = ({ mcpClient }) if (!mcpClient) return; try { - // Access private _transport property using type cast const client = mcpClient as unknown as { _transport?: unknown }; const transport = client._transport as unknown as TransportWithStats; @@ -210,36 +197,31 @@ const StreamableHttpStats: React.FC = ({ mcpClient }) const transportStats = transport.getTransportStats(); setStats(transportStats); - // Get active streams if available if (transport.getActiveStreams && typeof transport.getActiveStreams === 'function') { setActiveStreams(transport.getActiveStreams()); } - // Register log callback if not already done if (transport.registerLogCallback && typeof transport.registerLogCallback === 'function' && !logCallbackRegistered.current) { transport.registerLogCallback((logEntry: TransportLogEntry) => { setLogs(prevLogs => { const newLogs = [...prevLogs, logEntry]; - // Update tool events based on logs const updatedToolEvents = processToolLogs(newLogs); setToolEvents(updatedToolEvents); - // Track HTTP status codes - if (logEntry.type === 'response' && logEntry.statusCode) { + if (logEntry.type === 'response' && typeof logEntry.statusCode === 'number') { + const statusCodeStr = logEntry.statusCode.toString(); setHttpStatus(prev => ({ ...prev, - [logEntry.statusCode.toString()]: (prev[logEntry.statusCode.toString()] || 0) + 1 + [statusCodeStr]: (prev[statusCodeStr] || 0) + 1 })); } - // Check for spec violations with updated logs and stats if (transportStats) { const violations = checkSpecViolations(newLogs, transportStats); setSpecViolations(violations); } - // Keep last 100 logs for memory efficiency return newLogs.slice(-100); }); }); @@ -253,7 +235,6 @@ const StreamableHttpStats: React.FC = ({ mcpClient }) fetchStats(); - // Refresh stats every 2 seconds const interval = setInterval(fetchStats, 2000); return () => clearInterval(interval); @@ -280,7 +261,7 @@ const StreamableHttpStats: React.FC = ({ mcpClient }) const formatJson = (data: unknown) => { try { return JSON.stringify(data, null, 2); - } catch (_) { + } catch { return 'Unable to format data'; } }; 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 }; From 09d7ff567912a90a00dcd16206ba81592ba243cc Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Sat, 29 Mar 2025 18:36:37 +0100 Subject: [PATCH 11/14] fix: restore makeRequest wrapper and fix build errors --- client/src/App.tsx | 63 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 5b8e0f1c..01225be4 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -251,8 +251,49 @@ const App = () => { setErrors((prev) => ({ ...prev, [tabKey]: null })); }; + // 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 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)); + } + }; + + // Add wrapper function for makeConnectionRequest to handle tab error management + const makeRequest = async ( + request: ClientRequest, + schema: T, + tabKey?: keyof typeof errors, + ): Promise> => { + try { + const response = await makeConnectionRequest(request, schema); + if (tabKey !== undefined) { + clearError(tabKey); + } + return response; + } catch (e) { + const errorString = (e as Error).message ?? String(e); + if (tabKey !== undefined) { + setErrors((prev) => ({ + ...prev, + [tabKey]: errorString, + })); + } + throw e; + } + }; + const listResources = async () => { - const response = await makeConnectionRequest( + const response = await makeRequest( { method: "resources/list" as const, params: nextResourceCursor ? { cursor: nextResourceCursor } : {}, @@ -265,7 +306,7 @@ const App = () => { }; const listResourceTemplates = async () => { - const response = await makeConnectionRequest( + const response = await makeRequest( { method: "resources/templates/list" as const, params: nextResourceTemplateCursor ? { cursor: nextResourceTemplateCursor } : {}, @@ -278,7 +319,7 @@ const App = () => { }; const readResource = async (uri: string) => { - const response = await makeConnectionRequest( + const response = await makeRequest( { method: "resources/read" as const, params: { uri }, @@ -291,7 +332,7 @@ const App = () => { const subscribeToResource = async (uri: string) => { if (!resourceSubscriptions.has(uri)) { - await makeConnectionRequest( + await makeRequest( { method: "resources/subscribe" as const, params: { uri }, @@ -307,7 +348,7 @@ const App = () => { const unsubscribeFromResource = async (uri: string) => { if (resourceSubscriptions.has(uri)) { - await makeConnectionRequest( + await makeRequest( { method: "resources/unsubscribe" as const, params: { uri }, @@ -322,7 +363,7 @@ const App = () => { }; const listPrompts = async () => { - const response = await makeConnectionRequest( + const response = await makeRequest( { method: "prompts/list" as const, params: nextPromptCursor ? { cursor: nextPromptCursor } : {}, @@ -335,7 +376,7 @@ const App = () => { }; const getPrompt = async (name: string, args: Record = {}) => { - const response = await makeConnectionRequest( + const response = await makeRequest( { method: "prompts/get" as const, params: { name, arguments: args }, @@ -347,7 +388,7 @@ const App = () => { }; const listTools = async () => { - const response = await makeConnectionRequest( + const response = await makeRequest( { method: "tools/list" as const, params: nextToolCursor ? { cursor: nextToolCursor } : {}, @@ -360,7 +401,7 @@ const App = () => { }; const callTool = async (name: string, params: Record) => { - const response = await makeConnectionRequest( + const response = await makeRequest( { method: "tools/call" as const, params: { @@ -382,7 +423,7 @@ const App = () => { }; const sendLogLevelRequest = async (level: LoggingLevel) => { - await makeConnectionRequest( + await makeRequest( { method: "logging/setLevel" as const, params: { level }, @@ -587,7 +628,7 @@ const App = () => { { - void makeConnectionRequest( + void makeRequest( { method: "ping" as const, }, From 0b2c1500667a2c6369ae8f20c0501b0d8807dd9c Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Sat, 29 Mar 2025 18:37:55 +0100 Subject: [PATCH 12/14] fix: make SamplingTab handlers optional and fix useConnection type compatibility --- client/src/components/SamplingTab.tsx | 10 +++++++--- client/src/lib/hooks/useConnection.ts | 11 +++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) 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/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index b651a4f4..98799d86 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -176,6 +176,16 @@ export function useConnection({ } }; + // Backward compatibility wrapper for old code using string options + 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, @@ -518,6 +528,7 @@ export function useConnection({ mcpClient, requestHistory, makeRequest, + makeConnectionRequest, sendNotification, handleCompletion, completionsSupported, From cd424b10e9a7eef6480ee0f01520603c77aa13ef Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Sat, 29 Mar 2025 18:46:39 +0100 Subject: [PATCH 13/14] Fix HTTP transport handling in useConnection hook --- client/src/lib/hooks/useConnection.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 98799d86..34e4ad41 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -176,7 +176,6 @@ export function useConnection({ } }; - // Backward compatibility wrapper for old code using string options const makeConnectionRequest = async ( request: ClientRequest, schema: T, From aedb7da9b1c7420fa1d91851930b37075422b1bd Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Sat, 29 Mar 2025 19:09:41 +0100 Subject: [PATCH 14/14] Change package name to @modelcontextprotocol/inspector-server and version to 0.7.0 --- README.md | 12 ++++++------ package.json | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7e535a47..fb5992ec 100644 --- a/README.md +++ b/README.md @@ -45,29 +45,29 @@ 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). diff --git a/package.json b/package.json index eb85d89b..dfdac202 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "mcp-debug", - "version": "0.7.2", + "name": "@modelcontextprotocol/inspector-server", + "version": "0.7.0", "description": "Model Context Protocol inspector with enhanced HTTP streaming and direct connection support", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com) and contributors",