From 55d870daac0f08909705bb8f27bcfb93006eceda Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Sat, 29 Mar 2025 16:25:08 +0100 Subject: [PATCH 1/3] add direct http transport --- client/src/App.tsx | 89 ++- client/src/components/StatsTab.tsx | 679 ++++++++++++++++++ client/src/components/StreamableHttpStats.tsx | 118 +++ client/src/components/ui/accordion.tsx | 58 ++ client/src/lib/directTransports.ts | 616 ++++++++++++++-- client/src/lib/hooks/useConnection.ts | 57 +- package-lock.json | 287 +++++++- package.json | 3 + server/src/streamableHttpTransport.ts | 66 +- 9 files changed, 1812 insertions(+), 161 deletions(-) create mode 100644 client/src/components/StatsTab.tsx create mode 100644 client/src/components/StreamableHttpStats.tsx create mode 100644 client/src/components/ui/accordion.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 32ee64011..f4ed7dccb 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -31,6 +31,7 @@ import { Hammer, Hash, MessageSquare, + BarChart, } from "lucide-react"; import { toast } from "react-toastify"; @@ -45,6 +46,7 @@ import RootsTab from "./components/RootsTab"; import SamplingTab, { PendingRequest } from "./components/SamplingTab"; import Sidebar from "./components/Sidebar"; import ToolsTab from "./components/ToolsTab"; +import StatsTab from "./components/StatsTab"; const params = new URLSearchParams(window.location.search); const PROXY_PORT = params.get("proxyPort") ?? "3000"; @@ -123,22 +125,6 @@ const MainApp = () => { const nextRequestId = useRef(0); const rootsRef = useRef([]); - const handleApproveSampling = (id: number, result: CreateMessageResult) => { - setPendingSampleRequests((prev) => { - const request = prev.find((r) => r.id === id); - request?.resolve(result); - return prev.filter((r) => r.id !== id); - }); - }; - - const handleRejectSampling = (id: number) => { - setPendingSampleRequests((prev) => { - const request = prev.find((r) => r.id === id); - request?.reject(new Error("Sampling request rejected")); - return prev.filter((r) => r.id !== id); - }); - }; - const [selectedResource, setSelectedResource] = useState( null, ); @@ -195,7 +181,7 @@ const MainApp = () => { ...prev, { id: nextRequestId.current++, - request: request as any, + request: request as unknown, resolve: resolve as (result: CreateMessageResult) => void, reject: reject as (error: Error) => void } as PendingRequest & { @@ -248,7 +234,7 @@ const MainApp = () => { fetch(`${PROXY_SERVER_URL}/config`) .then((response) => response.json()) .then((data) => { - setEnv(data.defaultEnvironment); + setEnv(data.defaultEnvironment || {}); if (data.defaultCommand) { setCommand(data.defaultCommand); } @@ -256,15 +242,34 @@ const MainApp = () => { setArgs(data.defaultArgs); } }) - .catch((error) => - console.error("Error fetching default environment:", error), - ); + .catch((error) => { + console.error("Error fetching default environment:", error); + // Set default empty environment to prevent UI blocking + setEnv({}); + }); }, []); useEffect(() => { rootsRef.current = roots; }, [roots]); + useEffect(() => { + console.log(`[App] Connection status changed to: ${connectionStatus}`); + console.log(`[App] Connection details - status: ${connectionStatus}, serverCapabilities: ${!!serverCapabilities}, mcpClient: ${!!mcpClient}`); + + if (connectionStatus === "connected" && mcpClient && !serverCapabilities) { + console.log("[App] Connection is established, but missing capabilities"); + try { + // Only log capabilities here, don't attempt to set them + // as we don't have the setter in this component + const caps = mcpClient.getServerCapabilities(); + console.log("[App] Retrieved capabilities directly:", caps); + } catch (e) { + console.error("[App] Error retrieving capabilities:", e); + } + } + }, [connectionStatus, serverCapabilities, mcpClient]); + useEffect(() => { if (!window.location.hash) { window.location.hash = "resources"; @@ -443,6 +448,23 @@ const MainApp = () => { setLogLevel(level); }; + const handleApproveSampling = (id: number, result: CreateMessageResult) => { + setPendingSampleRequests((prev) => { + const request = prev.find((r) => r.id === id); + request?.resolve(result); + return prev.filter((r) => r.id !== id); + }); + }; + + const handleRejectSampling = (id: number) => { + setPendingSampleRequests((prev) => { + const request = prev.find((r) => r.id === id); + request?.reject(new Error("Sampling request rejected")); + return prev.filter((r) => r.id !== id); + }); + }; + + return (
{ />
- {mcpClient ? ( + {connectionStatus === "connected" && serverCapabilities ? ( { Roots + + + Stats +
@@ -656,10 +682,27 @@ const MainApp = () => { setRoots={setRoots} onRootsChange={handleRootsChange} /> + )}
+ ) : connectionStatus === "connected" && mcpClient ? ( +
+

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

+ +
) : (

diff --git a/client/src/components/StatsTab.tsx b/client/src/components/StatsTab.tsx new file mode 100644 index 000000000..25a71ea73 --- /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 000000000..0855e39da --- /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 000000000..cf69e326d --- /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 8b0b36d42..61424c124 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 61cfa7d16..8b821f355 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 e6c4bb4bb..d52a1d55d 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 37eeb5415..eb85d89b7 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 990680e95..fcce0b5a3 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 2/3] 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 0855e39da..cbcdcdafe 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 3/3] 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 cbcdcdafe..7a90dacae 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 000000000..239baa67d --- /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 };