From 9b624e8c87ed0cb9827a3a121372b37078111625 Mon Sep 17 00:00:00 2001 From: Gavin Aboulhosn Date: Mon, 16 Dec 2024 14:28:02 -0500 Subject: [PATCH 1/4] feat(completions): integrate MCP Completion support into Prompts and Resources tabs - create useCompletion hook to fetch completions with debouncing and abort control - Updated `PromptsTab.tsx` and `ResourcesTab.tsx` to utilize the `Combobox` component and `useCompletions` hook, enabling argument autocompletion for prompts and resource URIs as per the MCP specification. - Added a combobox to show completions --- client/package.json | 3 + client/src/App.tsx | 364 +++++++++++++----- client/src/components/PromptsTab.tsx | 45 ++- client/src/components/ResourcesTab.tsx | 58 ++- client/src/components/ui/combobox.tsx | 97 +++++ client/src/components/ui/command.tsx | 150 ++++++++ client/src/components/ui/dialog.tsx | 121 ++++++ client/src/components/ui/popover.tsx | 31 ++ client/src/lib/useCompletion.ts | 126 +++++++ package-lock.json | 500 +++++++++++++++++++++++-- 10 files changed, 1350 insertions(+), 145 deletions(-) create mode 100644 client/src/components/ui/combobox.tsx create mode 100644 client/src/components/ui/command.tsx create mode 100644 client/src/components/ui/dialog.tsx create mode 100644 client/src/components/ui/popover.tsx create mode 100644 client/src/lib/useCompletion.ts diff --git a/client/package.json b/client/package.json index 69021c8..57f8596 100644 --- a/client/package.json +++ b/client/package.json @@ -22,13 +22,16 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.4.1", + "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.3", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cmdk": "^1.0.4", "lucide-react": "^0.447.0", "pkce-challenge": "^4.1.0", "react": "^18.3.1", diff --git a/client/src/App.tsx b/client/src/App.tsx index 246e035..b4f2b80 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,26 +1,39 @@ +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, } from "@modelcontextprotocol/sdk/types.js"; -import React, { Suspense, useEffect, useRef, useState } from "react"; -import { useConnection } from "./lib/hooks/useConnection"; -import { useDraggablePane } from "./lib/hooks/useDraggablePane"; +import { useCallback, useEffect, useRef, useState } from "react"; -import { StdErrNotification } from "./lib/notificationTypes"; +import { + Notification, + StdErrNotification, + StdErrNotificationSchema, +} from "./lib/notificationTypes"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { @@ -33,7 +46,7 @@ import { } from "lucide-react"; import { toast } from "react-toastify"; -import { z } from "zod"; +import { z, type ZodType } from "zod"; import "./App.css"; import ConsoleTab from "./components/ConsoleTab"; import HistoryAndNotifications from "./components/History"; @@ -45,22 +58,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 = () => { - // Handle OAuth callback route - if (window.location.pathname === "/oauth/callback") { - const OAuthCallback = React.lazy( - () => import("./components/OAuthCallback"), - ); - return ( - Loading...}> - - - ); - } + const [connectionStatus, setConnectionStatus] = useState< + "disconnected" | "connected" | "error" + >("disconnected"); + const [serverCapabilities, setServerCapabilities] = + useState(null); const [resources, setResources] = useState([]); const [resourceTemplates, setResourceTemplates] = useState< ResourceTemplate[] @@ -83,14 +96,12 @@ const App = () => { return localStorage.getItem("lastArgs") || ""; }); - 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 [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[] @@ -141,64 +152,49 @@ 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 { height: historyPaneHeight, handleDragStart } = useDraggablePane(300); + const handleDragStart = useCallback( + (e: React.MouseEvent) => { + setIsDragging(true); + dragStartY.current = e.clientY; + dragStartHeight.current = historyPaneHeight; + document.body.style.userSelect = "none"; + }, + [historyPaneHeight], + ); - 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]); + 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, - ]); - }, - onPendingRequest: (request, resolve, reject) => { - setPendingSampleRequests((prev) => [ - ...prev, - { id: nextRequestId.current++, request, resolve, reject }, - ]); - }, - getRoots: () => rootsRef.current, - }); + [isDragging], + ); - 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; + 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]); useEffect(() => { localStorage.setItem("lastCommand", command); @@ -208,31 +204,6 @@ 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()) @@ -260,10 +231,121 @@ 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 handleCompletion = async ( + ref: ResourceReference | PromptReference, + argName: string, + value: string, + signal?: AbortSignal, + ) => { + if (!mcpClient) { + throw new Error("MCP client not connected"); + } + + const request: ClientRequest = { + method: "completion/complete", + params: { + argument: { + name: argName, + value, + }, + ref, + }, + }; + + try { + const response = await mcpClient.complete(request.params, { + signal, + }); + pushHistory(request, response); + + return response?.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( { @@ -366,6 +448,82 @@ 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 (
{ clearError("resources"); setSelectedResource(resource); }} + onComplete={handleCompletion} resourceContent={resourceContent} nextCursor={nextResourceCursor} nextTemplateCursor={nextResourceTemplateCursor} @@ -507,6 +666,7 @@ const App = () => { clearError("prompts"); setSelectedPrompt(prompt); }} + onComplete={handleCompletion} promptContent={promptContent} nextCursor={nextPromptCursor} error={errors.prompts} diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx index df8b8a5..6799290 100644 --- a/client/src/components/PromptsTab.tsx +++ b/client/src/components/PromptsTab.tsx @@ -1,13 +1,17 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { Combobox } from "@/components/ui/combobox"; import { Label } from "@/components/ui/label"; import { TabsContent } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; -import { ListPromptsResult } from "@modelcontextprotocol/sdk/types.js"; +import { + ListPromptsResult, + PromptReference, +} from "@modelcontextprotocol/sdk/types.js"; import { AlertCircle } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import ListPane from "./ListPane"; +import { useCompletions } from "@/lib/useCompletion"; export type Prompt = { name: string; @@ -26,6 +30,7 @@ const PromptsTab = ({ getPrompt, selectedPrompt, setSelectedPrompt, + onComplete, promptContent, nextCursor, error, @@ -36,14 +41,37 @@ const PromptsTab = ({ getPrompt: (name: string, args: Record) => void; selectedPrompt: Prompt | null; setSelectedPrompt: (prompt: Prompt) => void; + onComplete: ( + ref: PromptReference, + argName: string, + value: string, + ) => Promise; promptContent: string; nextCursor: ListPromptsResult["nextCursor"]; error: string | null; }) => { const [promptArgs, setPromptArgs] = useState>({}); + const { completions, clearCompletions, requestCompletions } = useCompletions({ + onComplete, + }); - const handleInputChange = (argName: string, value: string) => { + useEffect(() => { + clearCompletions(); + }, [clearCompletions, selectedPrompt]); + + const handleInputChange = async (argName: string, value: string) => { setPromptArgs((prev) => ({ ...prev, [argName]: value })); + + if (selectedPrompt) { + requestCompletions( + { + type: "ref/prompt", + name: selectedPrompt.name, + }, + argName, + value, + ); + } }; const handleGetPrompt = () => { @@ -96,14 +124,17 @@ const PromptsTab = ({ {selectedPrompt.arguments?.map((arg) => (
- - handleInputChange(arg.name, e.target.value) + onChange={(value) => handleInputChange(arg.name, value)} + onInputChange={(value) => + handleInputChange(arg.name, value) } + options={completions[arg.name] || []} /> + {arg.description && (

{arg.description} diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index e948881..f661325 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -1,16 +1,19 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Combobox } from "@/components/ui/combobox"; import { TabsContent } from "@/components/ui/tabs"; import { ListResourcesResult, Resource, ResourceTemplate, ListResourceTemplatesResult, + ResourceReference, } from "@modelcontextprotocol/sdk/types.js"; import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react"; import ListPane from "./ListPane"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { useCompletions } from "@/lib/useCompletion"; const ResourcesTab = ({ resources, @@ -22,6 +25,7 @@ const ResourcesTab = ({ readResource, selectedResource, setSelectedResource, + onComplete, resourceContent, nextCursor, nextTemplateCursor, @@ -36,6 +40,11 @@ const ResourcesTab = ({ readResource: (uri: string) => void; selectedResource: Resource | null; setSelectedResource: (resource: Resource | null) => void; + onComplete: ( + ref: ResourceReference, + argName: string, + value: string, + ) => Promise; resourceContent: string; nextCursor: ListResourcesResult["nextCursor"]; nextTemplateCursor: ListResourceTemplatesResult["nextCursor"]; @@ -47,6 +56,14 @@ const ResourcesTab = ({ {}, ); + const { clearCompletions, completions, requestCompletions } = useCompletions({ + onComplete, + }); + + useEffect(() => { + clearCompletions(); + }, [clearCompletions]); + const fillTemplate = ( template: string, values: Record, @@ -57,6 +74,21 @@ const ResourcesTab = ({ ); }; + const handleTemplateValueChange = async (key: string, value: string) => { + setTemplateValues((prev) => ({ ...prev, [key]: value })); + + if (selectedTemplate?.uriTemplate) { + requestCompletions( + { + type: "ref/resource", + uri: selectedTemplate.uriTemplate, + }, + key, + value, + ); + } + }; + const handleReadTemplateResource = () => { if (selectedTemplate) { const uri = fillTemplate(selectedTemplate.uriTemplate, templateValues); @@ -162,22 +194,18 @@ const ResourcesTab = ({ const key = param.slice(1, -1); return (

- - {key} + - setTemplateValues({ - ...templateValues, - [key]: e.target.value, - }) + onChange={(value) => + handleTemplateValueChange(key, value) } - className="mt-1" + onInputChange={(value) => + handleTemplateValueChange(key, value) + } + options={completions[key] || []} />
); diff --git a/client/src/components/ui/combobox.tsx b/client/src/components/ui/combobox.tsx new file mode 100644 index 0000000..0262408 --- /dev/null +++ b/client/src/components/ui/combobox.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +interface ComboboxProps { + value: string; + onChange: (value: string) => void; + onInputChange: (value: string) => void; + options: string[]; + placeholder?: string; + emptyMessage?: string; + id?: string; +} + +export function Combobox({ + value, + onChange, + onInputChange, + options = [], + placeholder = "Select...", + emptyMessage = "No results found.", + id, +}: ComboboxProps) { + const [open, setOpen] = React.useState(false); + + const handleSelect = React.useCallback( + (option: string) => { + onChange(option); + setOpen(false); + }, + [onChange], + ); + + const handleInputChange = React.useCallback( + (value: string) => { + onInputChange(value); + }, + [onInputChange], + ); + + return ( + + + + + + + + {emptyMessage} + + {options.map((option) => ( + handleSelect(option)} + > + + {option} + + ))} + + + + + ); +} diff --git a/client/src/components/ui/command.tsx b/client/src/components/ui/command.tsx new file mode 100644 index 0000000..bc534b9 --- /dev/null +++ b/client/src/components/ui/command.tsx @@ -0,0 +1,150 @@ +import * as React from "react"; +import { type DialogProps } from "@radix-ui/react-dialog"; +import { Command as CommandPrimitive } from "cmdk"; +import { cn } from "@/lib/utils"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +const CommandDialog = ({ children, ...props }: DialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/client/src/components/ui/dialog.tsx b/client/src/components/ui/dialog.tsx new file mode 100644 index 0000000..07447bf --- /dev/null +++ b/client/src/components/ui/dialog.tsx @@ -0,0 +1,121 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { cn } from "@/lib/utils"; +import { Cross2Icon } from "@radix-ui/react-icons"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/client/src/components/ui/popover.tsx b/client/src/components/ui/popover.tsx new file mode 100644 index 0000000..34a106f --- /dev/null +++ b/client/src/components/ui/popover.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; + +import { cn } from "@/lib/utils"; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverAnchor = PopoverPrimitive.Anchor; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/client/src/lib/useCompletion.ts b/client/src/lib/useCompletion.ts new file mode 100644 index 0000000..621dd8c --- /dev/null +++ b/client/src/lib/useCompletion.ts @@ -0,0 +1,126 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import { + ResourceReference, + PromptReference, +} from "@modelcontextprotocol/sdk/types.js"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function debounce PromiseLike>( + func: T, + wait: number, +): (...args: Parameters) => void { + let timeout: ReturnType; + return function (...args: Parameters) { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; +} + +interface UseCompletionsOptions { + onComplete: ( + ref: T, + argName: string, + value: string, + signal?: AbortSignal, + ) => Promise; + debounceMs?: number; +} + +interface CompletionState { + completions: Record; + loading: Record; + error: Record; +} + +export function useCompletions({ + onComplete, + debounceMs = 300, +}: UseCompletionsOptions) { + const [state, setState] = useState({ + completions: {}, + loading: {}, + error: {}, + }); + + const completeRef = useRef(onComplete); + completeRef.current = onComplete; + + const abortControllerRef = useRef(null); + + const cleanup = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }, []); + + // Cleanup on unmount + useEffect(() => { + return cleanup; + }, [cleanup]); + + const requestCompletions = useCallback( + debounce(async (ref: T, argName: string, value: string) => { + // Abort any pending request + cleanup(); + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setState((prev) => ({ + ...prev, + loading: { ...prev.loading, [argName]: true }, + error: { ...prev.error, [argName]: null }, + })); + + try { + const values = await completeRef.current( + ref, + argName, + value, + abortController.signal, + ); + + // Check if this request was aborted + if (!abortController.signal.aborted) { + setState((prev) => ({ + ...prev, + completions: { ...prev.completions, [argName]: values }, + loading: { ...prev.loading, [argName]: false }, + })); + } + } catch (err) { + // Only update state if the request wasn't aborted + if (!abortController.signal.aborted) { + const error = err instanceof Error ? err.message : String(err); + setState((prev) => ({ + ...prev, + loading: { ...prev.loading, [argName]: false }, + error: { ...prev.error, [argName]: error }, + })); + } + } finally { + // Clear the abort controller if it's still the current one + if (abortControllerRef.current === abortController) { + abortControllerRef.current = null; + } + } + }, debounceMs), + [cleanup, debounceMs], + ); + + const clearCompletions = useCallback(() => { + cleanup(); + setState({ + completions: {}, + loading: {}, + error: {}, + }); + }, [cleanup]); + + return { + requestCompletions, + clearCompletions, + ...state, + }; +} diff --git a/package-lock.json b/package-lock.json index 0260f17..a0895cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,13 +35,16 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.4.1", + "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.3", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cmdk": "^1.0.4", "lucide-react": "^0.447.0", "pkce-challenge": "^4.1.0", "react": "^18.3.1", @@ -1206,9 +1209,9 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.4.1.tgz", - "integrity": "sha512-wS6YC4lkUZ9QpP+/7NBTlVNiEvsnyl0xF7rRusLF+RsG0xDPc/zWR7fEEyhKnnNutGsDAZh59l/AeoWGwIb1+g==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.5.0.tgz", + "integrity": "sha512-IJ+5iVVs8FCumIHxWqpwgkwOzyhtHVKy45s6Ug7Dv0MfRpaYisH8QQ87rIWeWdOzlk8sfhitZ7HCyQZk7d6b8w==", "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -1221,18 +1224,6 @@ "node": ">=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", @@ -1384,6 +1375,195 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.3.tgz", + "integrity": "sha512-ujGvqQNkZ0J7caQyl8XuZRj2/TIrYcOGwqz5TeD1OMcCdfBuEMP0D12ve+8J5F9XuNUth3FAKFWo/wt0E/GJrQ==", + "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-dismissable-layer": "1.1.2", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.6.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-dialog/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-dialog/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-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.2.tgz", + "integrity": "sha512-kEHnlhv7wUggvhuJPkyw4qspXLJOdYoAP4dO2c8ngGuXTq1w/HZp1YeVB+NQ2KbH1iEG+pvOCGYSqh9HZOz6hg==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "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-dialog/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.1.tgz", + "integrity": "sha512-01omzJAYRxXdG2/he/+xy+c8a8gCydoQ1yOxnWNcRhrrBW5W+RQJ22EK1SaO8tb3WoUsuEw7mJjBozPzihDFjA==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "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-dialog/node_modules/@radix-ui/react-portal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz", + "integrity": "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.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-dialog/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-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "dependencies": { + "@radix-ui/react-slot": "1.1.1" + }, + "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-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "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-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -1516,6 +1696,249 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.3.tgz", + "integrity": "sha512-MBDKFwRe6fi0LT8m/Jl4V8J3WbS/UfXJtsgg8Ym5w5AyPG3XfHH4zhBp1P8HmZK83T8J7UzVm6/JpDE3WMl1Dw==", + "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-dismissable-layer": "1.1.2", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.6.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-popover/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-popover/node_modules/@radix-ui/react-arrow": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz", + "integrity": "sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "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-popover/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-popover/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.2.tgz", + "integrity": "sha512-kEHnlhv7wUggvhuJPkyw4qspXLJOdYoAP4dO2c8ngGuXTq1w/HZp1YeVB+NQ2KbH1iEG+pvOCGYSqh9HZOz6hg==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "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-popover/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.1.tgz", + "integrity": "sha512-01omzJAYRxXdG2/he/+xy+c8a8gCydoQ1yOxnWNcRhrrBW5W+RQJ22EK1SaO8tb3WoUsuEw7mJjBozPzihDFjA==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "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-popover/node_modules/@radix-ui/react-popper": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz", + "integrity": "sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "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-popover/node_modules/@radix-ui/react-portal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz", + "integrity": "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.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-popover/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-popover/node_modules/@radix-ui/react-primitive": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "dependencies": { + "@radix-ui/react-slot": "1.1.1" + }, + "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-popover/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "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-popper": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", @@ -3151,6 +3574,21 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.4.tgz", + "integrity": "sha512-AnsjfHyHpQ/EFeAnG216WY7A5LiYCoZzCSygiLvfXC3H3LFGCprErteUcszaVluGOhuOTbJS3jWHrSDYPBBygg==", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.0", + "use-sync-external-store": "^1.2.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3700,6 +4138,18 @@ "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", @@ -6269,6 +6719,14 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", + "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6300,9 +6758,9 @@ } }, "node_modules/vite": { - "version": "5.4.12", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.12.tgz", - "integrity": "sha512-KwUaKB27TvWwDJr1GjjWthLMATbGEbeWYZIbGZ5qFIsgPP3vWzLu4cVooqhm5/Z2SPDUMjyPVjTztm5tYKwQxA==", + "version": "5.4.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", + "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6948,9 +7406,9 @@ } }, "node_modules/zod": { - "version": "3.24.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", - "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" From c66feff37d4e171d3cf0029d1c2497eea1611232 Mon Sep 17 00:00:00 2001 From: Gavin Aboulhosn Date: Wed, 12 Feb 2025 11:41:46 -0500 Subject: [PATCH 2/4] update completions branch --- client/src/App.tsx | 299 +++++++++++++-------------------------------- package-lock.json | 36 +++--- 2 files changed, 106 insertions(+), 229 deletions(-) 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" From 7f713fe40e574e9d273c2d76524c478cb20633b7 Mon Sep 17 00:00:00 2001 From: Gavin Aboulhosn Date: Wed, 12 Feb 2025 19:05:51 -0500 Subject: [PATCH 3/4] refactor(completions): improve completion handling and error states - Move completion logic from App.tsx to useConnection hook - Replace useCompletion with simpler useCompletionState hook - Add graceful fallback for servers without completion support - Improve error handling and state management - Update PromptsTab and ResourcesTab to use new completion API - Add type safety improvements across completion interfaces --- client/src/App.tsx | 43 +------ client/src/components/PromptsTab.tsx | 16 +-- client/src/components/ResourcesTab.tsx | 16 +-- client/src/lib/hooks/useCompletionState.ts | 76 +++++++++++++ client/src/lib/hooks/useConnection.ts | 69 ++++++++++- client/src/lib/useCompletion.ts | 126 --------------------- 6 files changed, 166 insertions(+), 180 deletions(-) create mode 100644 client/src/lib/hooks/useCompletionState.ts delete mode 100644 client/src/lib/useCompletion.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 1d6dbe4..a9adea5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -15,9 +15,6 @@ import { Root, ServerNotification, Tool, - PromptReference, - ResourceReference, - CompleteResultSchema, } from "@modelcontextprotocol/sdk/types.js"; import React, { Suspense, useEffect, useRef, useState } from "react"; import { useConnection } from "./lib/hooks/useConnection"; @@ -154,6 +151,8 @@ const App = () => { requestHistory, makeRequest: makeConnectionRequest, sendNotification, + handleCompletion, + completionsSupported, connect: connectMcpServer, } = useConnection({ transportType, @@ -267,38 +266,6 @@ const App = () => { } }; - const handleCompletion = async ( - ref: ResourceReference | PromptReference, - argName: string, - value: string, - signal?: AbortSignal, - ) => { - if (!mcpClient) { - throw new Error("MCP client not connected"); - } - - const request: ClientRequest = { - method: "completion/complete", - params: { - argument: { - name: argName, - value, - }, - ref, - }, - }; - - try { - const result = await makeRequest(request, CompleteResultSchema); - return result.completion.values; - } catch (e: unknown) { - const errorMessage = e instanceof Error ? e.message : String(e); - - toast.error(errorMessage); - throw e; - } - }; - const listResources = async () => { const response = await makeRequest( { @@ -518,7 +485,8 @@ const App = () => { clearError("resources"); setSelectedResource(resource); }} - onComplete={handleCompletion} + handleCompletion={handleCompletion} + completionsSupported={completionsSupported} resourceContent={resourceContent} nextCursor={nextResourceCursor} nextTemplateCursor={nextResourceTemplateCursor} @@ -543,7 +511,8 @@ const App = () => { clearError("prompts"); setSelectedPrompt(prompt); }} - onComplete={handleCompletion} + handleCompletion={handleCompletion} + completionsSupported={completionsSupported} promptContent={promptContent} nextCursor={nextPromptCursor} error={errors.prompts} diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx index 6799290..f88b16f 100644 --- a/client/src/components/PromptsTab.tsx +++ b/client/src/components/PromptsTab.tsx @@ -7,11 +7,12 @@ import { Textarea } from "@/components/ui/textarea"; import { ListPromptsResult, PromptReference, + ResourceReference, } from "@modelcontextprotocol/sdk/types.js"; import { AlertCircle } from "lucide-react"; import { useEffect, useState } from "react"; import ListPane from "./ListPane"; -import { useCompletions } from "@/lib/useCompletion"; +import { useCompletionState } from "@/lib/hooks/useCompletionState"; export type Prompt = { name: string; @@ -30,7 +31,8 @@ const PromptsTab = ({ getPrompt, selectedPrompt, setSelectedPrompt, - onComplete, + handleCompletion, + completionsSupported, promptContent, nextCursor, error, @@ -41,19 +43,19 @@ const PromptsTab = ({ getPrompt: (name: string, args: Record) => void; selectedPrompt: Prompt | null; setSelectedPrompt: (prompt: Prompt) => void; - onComplete: ( - ref: PromptReference, + handleCompletion: ( + ref: PromptReference | ResourceReference, argName: string, value: string, ) => Promise; + completionsSupported: boolean; promptContent: string; nextCursor: ListPromptsResult["nextCursor"]; error: string | null; }) => { const [promptArgs, setPromptArgs] = useState>({}); - const { completions, clearCompletions, requestCompletions } = useCompletions({ - onComplete, - }); + const { completions, clearCompletions, requestCompletions } = + useCompletionState(handleCompletion, completionsSupported); useEffect(() => { clearCompletions(); diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index f661325..93127a9 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -9,11 +9,12 @@ import { ResourceTemplate, ListResourceTemplatesResult, ResourceReference, + PromptReference, } from "@modelcontextprotocol/sdk/types.js"; import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react"; import ListPane from "./ListPane"; import { useEffect, useState } from "react"; -import { useCompletions } from "@/lib/useCompletion"; +import { useCompletionState } from "@/lib/hooks/useCompletionState"; const ResourcesTab = ({ resources, @@ -25,7 +26,8 @@ const ResourcesTab = ({ readResource, selectedResource, setSelectedResource, - onComplete, + handleCompletion, + completionsSupported, resourceContent, nextCursor, nextTemplateCursor, @@ -40,11 +42,12 @@ const ResourcesTab = ({ readResource: (uri: string) => void; selectedResource: Resource | null; setSelectedResource: (resource: Resource | null) => void; - onComplete: ( - ref: ResourceReference, + handleCompletion: ( + ref: ResourceReference | PromptReference, argName: string, value: string, ) => Promise; + completionsSupported: boolean; resourceContent: string; nextCursor: ListResourcesResult["nextCursor"]; nextTemplateCursor: ListResourceTemplatesResult["nextCursor"]; @@ -56,9 +59,8 @@ const ResourcesTab = ({ {}, ); - const { clearCompletions, completions, requestCompletions } = useCompletions({ - onComplete, - }); + const { completions, clearCompletions, requestCompletions } = + useCompletionState(handleCompletion, completionsSupported); useEffect(() => { clearCompletions(); diff --git a/client/src/lib/hooks/useCompletionState.ts b/client/src/lib/hooks/useCompletionState.ts new file mode 100644 index 0000000..1bbbf3f --- /dev/null +++ b/client/src/lib/hooks/useCompletionState.ts @@ -0,0 +1,76 @@ +import { useState, useCallback, useEffect } from "react"; +import { ResourceReference, PromptReference } from "@modelcontextprotocol/sdk/types.js"; + +interface CompletionState { + completions: Record; + loading: Record; + error: Record; +} + +export function useCompletionState( + handleCompletion: ( + ref: ResourceReference | PromptReference, + argName: string, + value: string, + ) => Promise, + completionsSupported: boolean = true, +) { + const [state, setState] = useState({ + completions: {}, + loading: {}, + error: {}, + }); + + const clearCompletions = useCallback(() => { + setState({ + completions: {}, + loading: {}, + error: {}, + }); + }, []); + + const requestCompletions = useCallback( + async (ref: ResourceReference | PromptReference, argName: string, value: string) => { + if (!completionsSupported) { + return; + } + + setState((prev) => ({ + ...prev, + loading: { ...prev.loading, [argName]: true }, + error: { ...prev.error, [argName]: null }, + })); + + try { + const values = await handleCompletion(ref, argName, value); + setState((prev) => ({ + ...prev, + completions: { ...prev.completions, [argName]: values }, + loading: { ...prev.loading, [argName]: false }, + })); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + setState((prev) => ({ + ...prev, + loading: { ...prev.loading, [argName]: false }, + error: { ...prev.error, [argName]: error }, + })); + } + }, + [handleCompletion, completionsSupported], + ); + + // Clear completions when support status changes + useEffect(() => { + if (!completionsSupported) { + clearCompletions(); + } + }, [completionsSupported, clearCompletions]); + + return { + ...state, + clearCompletions, + requestCompletions, + completionsSupported, + }; +} diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 6c42c3f..b72c92f 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -12,6 +12,10 @@ import { Request, Result, ServerCapabilities, + PromptReference, + ResourceReference, + McpError, + CompleteResultSchema, } from "@modelcontextprotocol/sdk/types.js"; import { useState } from "react"; import { toast } from "react-toastify"; @@ -36,6 +40,11 @@ interface UseConnectionOptions { getRoots?: () => any[]; } +interface RequestOptions { + signal?: AbortSignal; + timeout?: number; +} + export function useConnection({ transportType, command, @@ -58,6 +67,7 @@ export function useConnection({ const [requestHistory, setRequestHistory] = useState< { request: string; response?: string }[] >([]); + const [completionsSupported, setCompletionsSupported] = useState(true); const pushHistory = (request: object, response?: object) => { setRequestHistory((prev) => [ @@ -72,7 +82,8 @@ export function useConnection({ const makeRequest = async ( request: ClientRequest, schema: T, - ) => { + options?: RequestOptions, + ): Promise> => { if (!mcpClient) { throw new Error("MCP client not connected"); } @@ -81,12 +92,12 @@ export function useConnection({ const abortController = new AbortController(); const timeoutId = setTimeout(() => { abortController.abort("Request timed out"); - }, requestTimeout); + }, options?.timeout ?? requestTimeout); let response; try { response = await mcpClient.request(request, schema, { - signal: abortController.signal, + signal: options?.signal ?? abortController.signal, }); pushHistory(request, response); } catch (error) { @@ -100,9 +111,58 @@ export function useConnection({ return response; } catch (e: unknown) { + // Check for Method not found error specifically for completions + if ( + request.method === "completion/complete" && + e instanceof McpError && + e.code === -32601 + ) { + setCompletionsSupported(false); + return { completion: { values: [] } } as z.output; + } + const errorString = (e as Error).message ?? String(e); toast.error(errorString); + throw e; + } + }; + const handleCompletion = async ( + ref: ResourceReference | PromptReference, + argName: string, + value: string, + signal?: AbortSignal, + ): Promise => { + if (!mcpClient || !completionsSupported) { + return []; + } + + const request: ClientRequest = { + method: "completion/complete", + params: { + argument: { + name: argName, + value, + }, + ref, + }, + }; + + try { + const response = await makeRequest(request, CompleteResultSchema, { + signal, + }); + return response?.completion.values || []; + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : String(e); + pushHistory(request, { error: errorMessage }); + + if (e instanceof McpError && e.code === -32601) { + setCompletionsSupported(false); + return []; + } + + toast.error(errorMessage); throw e; } }; @@ -238,6 +298,7 @@ export function useConnection({ const capabilities = client.getServerCapabilities(); setServerCapabilities(capabilities ?? null); + setCompletionsSupported(true); // Reset completions support on new connection if (onPendingRequest) { client.setRequestHandler(CreateMessageRequestSchema, (request) => { @@ -268,6 +329,8 @@ export function useConnection({ requestHistory, makeRequest, sendNotification, + handleCompletion, + completionsSupported, connect, }; } diff --git a/client/src/lib/useCompletion.ts b/client/src/lib/useCompletion.ts deleted file mode 100644 index 621dd8c..0000000 --- a/client/src/lib/useCompletion.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { useState, useCallback, useRef, useEffect } from "react"; -import { - ResourceReference, - PromptReference, -} from "@modelcontextprotocol/sdk/types.js"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function debounce PromiseLike>( - func: T, - wait: number, -): (...args: Parameters) => void { - let timeout: ReturnType; - return function (...args: Parameters) { - clearTimeout(timeout); - timeout = setTimeout(() => func(...args), wait); - }; -} - -interface UseCompletionsOptions { - onComplete: ( - ref: T, - argName: string, - value: string, - signal?: AbortSignal, - ) => Promise; - debounceMs?: number; -} - -interface CompletionState { - completions: Record; - loading: Record; - error: Record; -} - -export function useCompletions({ - onComplete, - debounceMs = 300, -}: UseCompletionsOptions) { - const [state, setState] = useState({ - completions: {}, - loading: {}, - error: {}, - }); - - const completeRef = useRef(onComplete); - completeRef.current = onComplete; - - const abortControllerRef = useRef(null); - - const cleanup = useCallback(() => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - }, []); - - // Cleanup on unmount - useEffect(() => { - return cleanup; - }, [cleanup]); - - const requestCompletions = useCallback( - debounce(async (ref: T, argName: string, value: string) => { - // Abort any pending request - cleanup(); - - const abortController = new AbortController(); - abortControllerRef.current = abortController; - - setState((prev) => ({ - ...prev, - loading: { ...prev.loading, [argName]: true }, - error: { ...prev.error, [argName]: null }, - })); - - try { - const values = await completeRef.current( - ref, - argName, - value, - abortController.signal, - ); - - // Check if this request was aborted - if (!abortController.signal.aborted) { - setState((prev) => ({ - ...prev, - completions: { ...prev.completions, [argName]: values }, - loading: { ...prev.loading, [argName]: false }, - })); - } - } catch (err) { - // Only update state if the request wasn't aborted - if (!abortController.signal.aborted) { - const error = err instanceof Error ? err.message : String(err); - setState((prev) => ({ - ...prev, - loading: { ...prev.loading, [argName]: false }, - error: { ...prev.error, [argName]: error }, - })); - } - } finally { - // Clear the abort controller if it's still the current one - if (abortControllerRef.current === abortController) { - abortControllerRef.current = null; - } - } - }, debounceMs), - [cleanup, debounceMs], - ); - - const clearCompletions = useCallback(() => { - cleanup(); - setState({ - completions: {}, - loading: {}, - error: {}, - }); - }, [cleanup]); - - return { - requestCompletions, - clearCompletions, - ...state, - }; -} From d9df5ff860e089080ce8229be096b351c17e2599 Mon Sep 17 00:00:00 2001 From: Gavin Aboulhosn Date: Wed, 12 Feb 2025 20:19:07 -0500 Subject: [PATCH 4/4] refactor(completions): restore debouncing and improve MCP error handling --- client/src/lib/hooks/useCompletionState.ts | 110 +++++++++++++++------ client/src/lib/hooks/useConnection.ts | 38 +++---- 2 files changed, 101 insertions(+), 47 deletions(-) diff --git a/client/src/lib/hooks/useCompletionState.ts b/client/src/lib/hooks/useCompletionState.ts index 1bbbf3f..694253b 100644 --- a/client/src/lib/hooks/useCompletionState.ts +++ b/client/src/lib/hooks/useCompletionState.ts @@ -1,10 +1,24 @@ -import { useState, useCallback, useEffect } from "react"; -import { ResourceReference, PromptReference } from "@modelcontextprotocol/sdk/types.js"; +import { useState, useCallback, useEffect, useRef } from "react"; +import { + ResourceReference, + PromptReference, +} from "@modelcontextprotocol/sdk/types.js"; interface CompletionState { completions: Record; loading: Record; - error: Record; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function debounce PromiseLike>( + func: T, + wait: number, +): (...args: Parameters) => void { + let timeout: ReturnType; + return function (...args: Parameters) { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; } export function useCompletionState( @@ -12,52 +26,90 @@ export function useCompletionState( ref: ResourceReference | PromptReference, argName: string, value: string, + signal?: AbortSignal, ) => Promise, completionsSupported: boolean = true, + debounceMs: number = 300, ) { const [state, setState] = useState({ completions: {}, loading: {}, - error: {}, }); + const abortControllerRef = useRef(null); + + const cleanup = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }, []); + + // Cleanup on unmount + useEffect(() => { + return cleanup; + }, [cleanup]); + const clearCompletions = useCallback(() => { + cleanup(); setState({ completions: {}, loading: {}, - error: {}, }); - }, []); + }, [cleanup]); const requestCompletions = useCallback( - async (ref: ResourceReference | PromptReference, argName: string, value: string) => { - if (!completionsSupported) { - return; - } + debounce( + async ( + ref: ResourceReference | PromptReference, + argName: string, + value: string, + ) => { + if (!completionsSupported) { + return; + } - setState((prev) => ({ - ...prev, - loading: { ...prev.loading, [argName]: true }, - error: { ...prev.error, [argName]: null }, - })); + cleanup(); + + const abortController = new AbortController(); + abortControllerRef.current = abortController; - try { - const values = await handleCompletion(ref, argName, value); setState((prev) => ({ ...prev, - completions: { ...prev.completions, [argName]: values }, - loading: { ...prev.loading, [argName]: false }, + loading: { ...prev.loading, [argName]: true }, })); - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - setState((prev) => ({ - ...prev, - loading: { ...prev.loading, [argName]: false }, - error: { ...prev.error, [argName]: error }, - })); - } - }, - [handleCompletion, completionsSupported], + + try { + const values = await handleCompletion( + ref, + argName, + value, + abortController.signal, + ); + + if (!abortController.signal.aborted) { + setState((prev) => ({ + ...prev, + completions: { ...prev.completions, [argName]: values }, + loading: { ...prev.loading, [argName]: false }, + })); + } + } catch (err) { + if (!abortController.signal.aborted) { + setState((prev) => ({ + ...prev, + loading: { ...prev.loading, [argName]: false }, + })); + } + } finally { + if (abortControllerRef.current === abortController) { + abortControllerRef.current = null; + } + } + }, + debounceMs, + ), + [handleCompletion, completionsSupported, cleanup, debounceMs], ); // Clear completions when support status changes diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index b72c92f..eb06175 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -16,6 +16,7 @@ import { ResourceReference, McpError, CompleteResultSchema, + ErrorCode, } from "@modelcontextprotocol/sdk/types.js"; import { useState } from "react"; import { toast } from "react-toastify"; @@ -43,6 +44,7 @@ interface UseConnectionOptions { interface RequestOptions { signal?: AbortSignal; timeout?: number; + suppressToast?: boolean; } export function useConnection({ @@ -111,18 +113,10 @@ export function useConnection({ return response; } catch (e: unknown) { - // Check for Method not found error specifically for completions - if ( - request.method === "completion/complete" && - e instanceof McpError && - e.code === -32601 - ) { - setCompletionsSupported(false); - return { completion: { values: [] } } as z.output; + if (!options?.suppressToast) { + const errorString = (e as Error).message ?? String(e); + toast.error(errorString); } - - const errorString = (e as Error).message ?? String(e); - toast.error(errorString); throw e; } }; @@ -151,32 +145,40 @@ export function useConnection({ try { const response = await makeRequest(request, CompleteResultSchema, { signal, + suppressToast: true, }); return response?.completion.values || []; } catch (e: unknown) { - const errorMessage = e instanceof Error ? e.message : String(e); - pushHistory(request, { error: errorMessage }); - - if (e instanceof McpError && e.code === -32601) { + // Disable completions silently if the server doesn't support them. + // See https://github.com/modelcontextprotocol/specification/discussions/122 + if (e instanceof McpError && e.code === ErrorCode.MethodNotFound) { setCompletionsSupported(false); return []; } - toast.error(errorMessage); + // Unexpected errors - show toast and rethrow + toast.error(e instanceof Error ? e.message : String(e)); throw e; } }; const sendNotification = async (notification: ClientNotification) => { if (!mcpClient) { - throw new Error("MCP client not connected"); + const error = new Error("MCP client not connected"); + toast.error(error.message); + throw error; } try { await mcpClient.notification(notification); + // Log successful notifications pushHistory(notification); } catch (e: unknown) { - toast.error((e as Error).message ?? String(e)); + if (e instanceof McpError) { + // Log MCP protocol errors + pushHistory(notification, { error: e.message }); + } + toast.error(e instanceof Error ? e.message : String(e)); throw e; } };