From dc49d46baa04dbfb9e14285f38353583bf8d4328 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Wed, 18 Dec 2024 12:35:23 -0800 Subject: [PATCH] refactor: extract draggable pane and connection logic into hooks - Create useDraggablePane hook for history pane drag behavior - Create useConnection hook for MCP client connection and requests - Update App.tsx to use both hooks --- client/src/App.tsx | 280 +++++------------------ client/src/lib/hooks/useConnection.ts | 188 +++++++++++++++ client/src/lib/hooks/useDraggablePane.ts | 44 ++++ 3 files changed, 292 insertions(+), 220 deletions(-) create mode 100644 client/src/lib/hooks/useConnection.ts create mode 100644 client/src/lib/hooks/useDraggablePane.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index f64276a..c225c30 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,37 +1,26 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { useDraggablePane } from "./lib/hooks/useDraggablePane"; +import { useConnection } from "./lib/hooks/useConnection"; import { - ClientNotification, ClientRequest, CompatibilityCallToolResult, CompatibilityCallToolResultSchema, - CreateMessageRequestSchema, CreateMessageResult, EmptyResultSchema, GetPromptResultSchema, ListPromptsResultSchema, ListResourcesResultSchema, ListResourceTemplatesResultSchema, - ListRootsRequestSchema, - ListToolsResultSchema, - ProgressNotificationSchema, ReadResourceResultSchema, - Request, + ListToolsResultSchema, Resource, ResourceTemplate, Root, ServerNotification, - Tool, - ServerCapabilitiesSchema, - Result, + Tool } from "@modelcontextprotocol/sdk/types.js"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; -import { - Notification, - StdErrNotification, - StdErrNotificationSchema, -} from "./lib/notificationTypes"; +import { StdErrNotification } from "./lib/notificationTypes"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { @@ -43,8 +32,7 @@ import { MessageSquare, } from "lucide-react"; -import { toast } from "react-toastify"; -import { z, type ZodType } from "zod"; +import { z } from "zod"; import "./App.css"; import ConsoleTab from "./components/ConsoleTab"; import HistoryAndNotifications from "./components/History"; @@ -56,21 +44,11 @@ import SamplingTab, { PendingRequest } from "./components/SamplingTab"; import Sidebar from "./components/Sidebar"; import ToolsTab from "./components/ToolsTab"; -type ServerCapabilities = z.infer; - -const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000; - const params = new URLSearchParams(window.location.search); const PROXY_PORT = params.get("proxyPort") ?? "3000"; -const REQUEST_TIMEOUT = - parseInt(params.get("timeout") ?? "") || DEFAULT_REQUEST_TIMEOUT_MSEC; const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`; const App = () => { - const [connectionStatus, setConnectionStatus] = useState< - "disconnected" | "connected" | "error" - >("disconnected"); - const [serverCapabilities, setServerCapabilities] = useState(null); const [resources, setResources] = useState([]); const [resourceTemplates, setResourceTemplates] = useState< ResourceTemplate[] @@ -95,10 +73,6 @@ const App = () => { const [sseUrl, setSseUrl] = useState("http://localhost:3001/sse"); const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio"); - const [requestHistory, setRequestHistory] = useState< - { request: string; response?: string }[] - >([]); - const [mcpClient, setMcpClient] = useState(null); const [notifications, setNotifications] = useState([]); const [stdErrNotifications, setStdErrNotifications] = useState< StdErrNotification[] @@ -149,49 +123,64 @@ const App = () => { >(); const [nextToolCursor, setNextToolCursor] = useState(); const progressTokenRef = useRef(0); - const [historyPaneHeight, setHistoryPaneHeight] = useState(300); - const [isDragging, setIsDragging] = useState(false); - const dragStartY = useRef(0); - const dragStartHeight = useRef(0); - const handleDragStart = useCallback( - (e: React.MouseEvent) => { - setIsDragging(true); - dragStartY.current = e.clientY; - dragStartHeight.current = historyPaneHeight; - document.body.style.userSelect = "none"; + const { + height: historyPaneHeight, + handleDragStart + } = useDraggablePane(300); + + const { + connectionStatus, + serverCapabilities, + mcpClient, + requestHistory, + makeRequest: makeConnectionRequest, + sendNotification, + connect: connectMcpServer + } = useConnection({ + transportType, + command, + args, + sseUrl, + env, + proxyServerUrl: PROXY_SERVER_URL, + onNotification: (notification) => { + setNotifications(prev => [...prev, notification as ServerNotification]); }, - [historyPaneHeight], - ); - - const handleDragMove = useCallback( - (e: MouseEvent) => { - if (!isDragging) return; - const deltaY = dragStartY.current - e.clientY; - const newHeight = Math.max( - 100, - Math.min(800, dragStartHeight.current + deltaY), - ); - setHistoryPaneHeight(newHeight); + onStdErrNotification: (notification) => { + setStdErrNotifications(prev => [...prev, notification as StdErrNotification]); }, - [isDragging], - ); + onPendingRequest: (request, resolve, reject) => { + setPendingSampleRequests(prev => [ + ...prev, + { id: nextRequestId.current++, request, resolve, reject } + ]); + }, + getRoots: () => rootsRef.current + }); - const handleDragEnd = useCallback(() => { - setIsDragging(false); - document.body.style.userSelect = ""; - }, []); - - useEffect(() => { - if (isDragging) { - window.addEventListener("mousemove", handleDragMove); - window.addEventListener("mouseup", handleDragEnd); - return () => { - window.removeEventListener("mousemove", handleDragMove); - window.removeEventListener("mouseup", handleDragEnd); - }; + const makeRequest = async ( + request: ClientRequest, + schema: T, + tabKey?: keyof typeof errors, + ) => { + try { + const response = await makeConnectionRequest(request, schema); + if (tabKey !== undefined) { + clearError(tabKey); + } + return response; + } catch (e) { + const errorString = (e as Error).message ?? String(e); + if (tabKey !== undefined) { + setErrors((prev) => ({ + ...prev, + [tabKey]: errorString, + })); + } + throw e; } - }, [isDragging, handleDragMove, handleDragEnd]); + }; useEffect(() => { localStorage.setItem("lastCommand", command); @@ -228,83 +217,10 @@ const App = () => { } }, []); - const pushHistory = (request: object, response?: object) => { - setRequestHistory((prev) => [ - ...prev, - { - request: JSON.stringify(request), - response: response !== undefined ? JSON.stringify(response) : undefined, - }, - ]); - }; - const clearError = (tabKey: keyof typeof errors) => { setErrors((prev) => ({ ...prev, [tabKey]: null })); }; - const makeRequest = async >( - request: ClientRequest, - schema: T, - tabKey?: keyof typeof errors, - ) => { - if (!mcpClient) { - throw new Error("MCP client not connected"); - } - - try { - const abortController = new AbortController(); - const timeoutId = setTimeout(() => { - abortController.abort("Request timed out"); - }, REQUEST_TIMEOUT); - - let response; - try { - response = await mcpClient.request(request, schema, { - signal: abortController.signal, - }); - pushHistory(request, response); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - pushHistory(request, { error: errorMessage }); - throw error; - } finally { - clearTimeout(timeoutId); - } - - if (tabKey !== undefined) { - clearError(tabKey); - } - - return response; - } catch (e: unknown) { - const errorString = (e as Error).message ?? String(e); - if (tabKey === undefined) { - toast.error(errorString); - } else { - setErrors((prev) => ({ - ...prev, - [tabKey]: errorString, - })); - } - - throw e; - } - }; - - const sendNotification = async (notification: ClientNotification) => { - if (!mcpClient) { - throw new Error("MCP client not connected"); - } - - try { - await mcpClient.notification(notification); - pushHistory(notification); - } catch (e: unknown) { - toast.error((e as Error).message ?? String(e)); - throw e; - } - }; - const listResources = async () => { const response = await makeRequest( { @@ -407,82 +323,6 @@ const App = () => { await sendNotification({ method: "notifications/roots/list_changed" }); }; - const connectMcpServer = async () => { - try { - const client = new Client( - { - name: "mcp-inspector", - version: "0.0.1", - }, - { - capabilities: { - // Support all client capabilities since we're an inspector tool - sampling: {}, - roots: { - listChanged: true, - }, - }, - }, - ); - - const backendUrl = new URL(`${PROXY_SERVER_URL}/sse`); - - backendUrl.searchParams.append("transportType", transportType); - if (transportType === "stdio") { - backendUrl.searchParams.append("command", command); - backendUrl.searchParams.append("args", args); - backendUrl.searchParams.append("env", JSON.stringify(env)); - } else { - backendUrl.searchParams.append("url", sseUrl); - } - - const clientTransport = new SSEClientTransport(backendUrl); - client.setNotificationHandler( - ProgressNotificationSchema, - (notification) => { - setNotifications((prevNotifications) => [ - ...prevNotifications, - notification, - ]); - }, - ); - - client.setNotificationHandler( - StdErrNotificationSchema, - (notification) => { - setStdErrNotifications((prevErrorNotifications) => [ - ...prevErrorNotifications, - notification, - ]); - }, - ); - - await client.connect(clientTransport); - - const capabilities = client.getServerCapabilities(); - setServerCapabilities(capabilities ?? null); - - client.setRequestHandler(CreateMessageRequestSchema, (request) => { - return new Promise((resolve, reject) => { - setPendingSampleRequests((prev) => [ - ...prev, - { id: nextRequestId.current++, request, resolve, reject }, - ]); - }); - }); - - client.setRequestHandler(ListRootsRequestSchema, async () => { - return { roots: rootsRef.current }; - }); - - setMcpClient(client); - setConnectionStatus("connected"); - } catch (e) { - console.error(e); - setConnectionStatus("error"); - } - }; - return (
; + proxyServerUrl: string; + requestTimeout?: number; + onNotification?: (notification: Notification) => void; + onStdErrNotification?: (notification: Notification) => void; + onPendingRequest?: (request: any, resolve: any, reject: any) => void; + getRoots?: () => any[]; +} + +export function useConnection({ + transportType, + command, + args, + sseUrl, + env, + proxyServerUrl, + requestTimeout = DEFAULT_REQUEST_TIMEOUT_MSEC, + onNotification, + onStdErrNotification, + onPendingRequest, + getRoots, +}: UseConnectionOptions) { + const [connectionStatus, setConnectionStatus] = useState<"disconnected" | "connected" | "error">("disconnected"); + const [serverCapabilities, setServerCapabilities] = useState(null); + const [mcpClient, setMcpClient] = useState(null); + const [requestHistory, setRequestHistory] = useState<{ request: string; response?: string }[]>([]); + + const pushHistory = (request: object, response?: object) => { + setRequestHistory((prev) => [ + ...prev, + { + request: JSON.stringify(request), + response: response !== undefined ? JSON.stringify(response) : undefined, + }, + ]); + }; + + const makeRequest = async ( + request: ClientRequest, + schema: T + ) => { + if (!mcpClient) { + throw new Error("MCP client not connected"); + } + + try { + const abortController = new AbortController(); + const timeoutId = setTimeout(() => { + abortController.abort("Request timed out"); + }, requestTimeout); + + let response; + try { + response = await mcpClient.request(request, schema, { + signal: abortController.signal, + }); + pushHistory(request, response); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + pushHistory(request, { error: errorMessage }); + throw error; + } finally { + clearTimeout(timeoutId); + } + + + return response; + } catch (e: unknown) { + const errorString = (e as Error).message ?? String(e); + toast.error(errorString); + + throw e; + } + }; + + const sendNotification = async (notification: ClientNotification) => { + if (!mcpClient) { + throw new Error("MCP client not connected"); + } + + try { + await mcpClient.notification(notification); + pushHistory(notification); + } catch (e: unknown) { + toast.error((e as Error).message ?? String(e)); + throw e; + } + }; + + const connect = async () => { + try { + const client = new Client( + { + name: "mcp-inspector", + version: "0.0.1", + }, + { + capabilities: { + sampling: {}, + roots: { + listChanged: true, + }, + }, + }, + ); + + const backendUrl = new URL(`${proxyServerUrl}/sse`); + + backendUrl.searchParams.append("transportType", transportType); + if (transportType === "stdio") { + backendUrl.searchParams.append("command", command); + backendUrl.searchParams.append("args", args); + backendUrl.searchParams.append("env", JSON.stringify(env)); + } else { + backendUrl.searchParams.append("url", sseUrl); + } + + const clientTransport = new SSEClientTransport(backendUrl); + + if (onNotification) { + client.setNotificationHandler(ProgressNotificationSchema, onNotification); + } + + if (onStdErrNotification) { + client.setNotificationHandler(StdErrNotificationSchema, onStdErrNotification); + } + + await client.connect(clientTransport); + + const capabilities = client.getServerCapabilities(); + setServerCapabilities(capabilities ?? null); + + if (onPendingRequest) { + client.setRequestHandler(CreateMessageRequestSchema, (request) => { + return new Promise((resolve, reject) => { + onPendingRequest(request, resolve, reject); + }); + }); + } + + if (getRoots) { + client.setRequestHandler(ListRootsRequestSchema, async () => { + return { roots: getRoots() }; + }); + } + + setMcpClient(client); + setConnectionStatus("connected"); + } catch (e) { + console.error(e); + setConnectionStatus("error"); + } + }; + + return { + connectionStatus, + serverCapabilities, + mcpClient, + requestHistory, + makeRequest, + sendNotification, + connect + }; +} \ No newline at end of file diff --git a/client/src/lib/hooks/useDraggablePane.ts b/client/src/lib/hooks/useDraggablePane.ts new file mode 100644 index 0000000..46b8fc3 --- /dev/null +++ b/client/src/lib/hooks/useDraggablePane.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export function useDraggablePane(initialHeight: number) { + const [height, setHeight] = useState(initialHeight); + const [isDragging, setIsDragging] = useState(false); + const dragStartY = useRef(0); + const dragStartHeight = useRef(0); + + const handleDragStart = useCallback((e: React.MouseEvent) => { + setIsDragging(true); + dragStartY.current = e.clientY; + dragStartHeight.current = height; + document.body.style.userSelect = "none"; + }, [height]); + + const handleDragMove = useCallback((e: MouseEvent) => { + if (!isDragging) return; + const deltaY = dragStartY.current - e.clientY; + const newHeight = Math.max(100, Math.min(800, dragStartHeight.current + deltaY)); + setHeight(newHeight); + }, [isDragging]); + + const handleDragEnd = useCallback(() => { + setIsDragging(false); + document.body.style.userSelect = ""; + }, []); + + useEffect(() => { + if (isDragging) { + window.addEventListener("mousemove", handleDragMove); + window.addEventListener("mouseup", handleDragEnd); + return () => { + window.removeEventListener("mousemove", handleDragMove); + window.removeEventListener("mouseup", handleDragEnd); + }; + } + }, [isDragging, handleDragMove, handleDragEnd]); + + return { + height, + isDragging, + handleDragStart + }; +} \ No newline at end of file