diff --git a/client/src/App.tsx b/client/src/App.tsx index b4f2b80..1d6dbe4 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,39 +1,29 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { - ClientNotification, ClientRequest, CompatibilityCallToolResult, CompatibilityCallToolResultSchema, - CreateMessageRequestSchema, CreateMessageResult, EmptyResultSchema, GetPromptResultSchema, ListPromptsResultSchema, ListResourcesResultSchema, ListResourceTemplatesResultSchema, - ListRootsRequestSchema, ListToolsResultSchema, - ProgressNotificationSchema, ReadResourceResultSchema, - Request, Resource, ResourceTemplate, Root, ServerNotification, Tool, - ServerCapabilitiesSchema, - Result, PromptReference, ResourceReference, + CompleteResultSchema, } from "@modelcontextprotocol/sdk/types.js"; -import { useCallback, useEffect, useRef, useState } from "react"; +import React, { Suspense, useEffect, useRef, useState } from "react"; +import { useConnection } from "./lib/hooks/useConnection"; +import { useDraggablePane } from "./lib/hooks/useDraggablePane"; -import { - Notification, - StdErrNotification, - StdErrNotificationSchema, -} from "./lib/notificationTypes"; +import { StdErrNotification } from "./lib/notificationTypes"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { @@ -46,7 +36,7 @@ import { } 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"; @@ -58,22 +48,22 @@ 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); + // Handle OAuth callback route + if (window.location.pathname === "/oauth/callback") { + const OAuthCallback = React.lazy( + () => import("./components/OAuthCallback"), + ); + return ( + Loading...}> + + + ); + } const [resources, setResources] = useState([]); const [resourceTemplates, setResourceTemplates] = useState< ResourceTemplate[] @@ -96,12 +86,14 @@ const App = () => { return localStorage.getItem("lastArgs") || ""; }); - 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 [sseUrl, setSseUrl] = useState(() => { + return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse"; + }); + const [transportType, setTransportType] = useState<"stdio" | "sse">(() => { + return ( + (localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio" + ); + }); const [notifications, setNotifications] = useState([]); const [stdErrNotifications, setStdErrNotifications] = useState< StdErrNotification[] @@ -152,49 +144,41 @@ 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], - ); - - 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]); + onPendingRequest: (request, resolve, reject) => { + setPendingSampleRequests((prev) => [ + ...prev, + { id: nextRequestId.current++, request, resolve, reject }, + ]); + }, + getRoots: () => rootsRef.current, + }); useEffect(() => { localStorage.setItem("lastCommand", command); @@ -204,6 +188,31 @@ const App = () => { localStorage.setItem("lastArgs", args); }, [args]); + useEffect(() => { + localStorage.setItem("lastSseUrl", sseUrl); + }, [sseUrl]); + + useEffect(() => { + localStorage.setItem("lastTransportType", transportType); + }, [transportType]); + + // Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback) + useEffect(() => { + const serverUrl = params.get("serverUrl"); + if (serverUrl) { + setSseUrl(serverUrl); + setTransportType("sse"); + // Remove serverUrl from URL without reloading the page + const newUrl = new URL(window.location.href); + newUrl.searchParams.delete("serverUrl"); + window.history.replaceState({}, "", newUrl.toString()); + // Show success toast for OAuth + toast.success("Successfully authenticated with OAuth"); + // Connect to the server + connectMcpServer(); + } + }, []); + useEffect(() => { fetch(`${PROXY_SERVER_URL}/config`) .then((response) => response.json()) @@ -231,66 +240,29 @@ 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 >( + 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); - } - + const response = await makeConnectionRequest(request, schema); if (tabKey !== undefined) { clearError(tabKey); } - return response; - } catch (e: unknown) { + } catch (e) { const errorString = (e as Error).message ?? String(e); - if (tabKey === undefined) { - toast.error(errorString); - } else { + if (tabKey !== undefined) { setErrors((prev) => ({ ...prev, [tabKey]: errorString, })); } - throw e; } }; @@ -317,35 +289,16 @@ const App = () => { }; try { - const response = await mcpClient.complete(request.params, { - signal, - }); - pushHistory(request, response); - - return response?.completion.values || []; + const result = await makeRequest(request, CompleteResultSchema); + return result.completion.values; } catch (e: unknown) { const errorMessage = e instanceof Error ? e.message : String(e); - pushHistory(request, { error: errorMessage }); toast.error(errorMessage); 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( { @@ -448,82 +401,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 (
=18" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/eventsource": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.2.tgz", + "integrity": "sha512-YolzkJNxsTL3tCJMWFxpxtG2sCjbZ4LQUBUrkdaJK0ub0p6lmJt+2+1SwhKjLc652lpH9L/79Ptez972H9tphw==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4138,18 +4150,6 @@ "node": ">= 0.6" } }, - "node_modules/eventsource": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.5.tgz", - "integrity": "sha512-LT/5J605bx5SNyE+ITBDiM3FxffBiq9un7Vx0EwMDM3vg8sWKx/tO2zC+LMqZ+smAM0F2hblaDZUVZF0te2pSw==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/eventsource-parser": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz", @@ -6758,9 +6758,9 @@ } }, "node_modules/vite": { - "version": "5.4.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", - "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "version": "5.4.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.12.tgz", + "integrity": "sha512-KwUaKB27TvWwDJr1GjjWthLMATbGEbeWYZIbGZ5qFIsgPP3vWzLu4cVooqhm5/Z2SPDUMjyPVjTztm5tYKwQxA==", "dev": true, "license": "MIT", "dependencies": { @@ -7406,9 +7406,9 @@ } }, "node_modules/zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks"