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; } };