Skip to content

Commit d9df5ff

Browse files
author
Gavin Aboulhosn
committed
refactor(completions): restore debouncing and improve MCP error handling
1 parent 5b451a7 commit d9df5ff

File tree

2 files changed

+101
-47
lines changed

2 files changed

+101
-47
lines changed

client/src/lib/hooks/useCompletionState.ts

Lines changed: 81 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,115 @@
1-
import { useState, useCallback, useEffect } from "react";
2-
import { ResourceReference, PromptReference } from "@modelcontextprotocol/sdk/types.js";
1+
import { useState, useCallback, useEffect, useRef } from "react";
2+
import {
3+
ResourceReference,
4+
PromptReference,
5+
} from "@modelcontextprotocol/sdk/types.js";
36

47
interface CompletionState {
58
completions: Record<string, string[]>;
69
loading: Record<string, boolean>;
7-
error: Record<string, string | null>;
10+
}
11+
12+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
13+
function debounce<T extends (...args: any[]) => PromiseLike<void>>(
14+
func: T,
15+
wait: number,
16+
): (...args: Parameters<T>) => void {
17+
let timeout: ReturnType<typeof setTimeout>;
18+
return function (...args: Parameters<T>) {
19+
clearTimeout(timeout);
20+
timeout = setTimeout(() => func(...args), wait);
21+
};
822
}
923

1024
export function useCompletionState(
1125
handleCompletion: (
1226
ref: ResourceReference | PromptReference,
1327
argName: string,
1428
value: string,
29+
signal?: AbortSignal,
1530
) => Promise<string[]>,
1631
completionsSupported: boolean = true,
32+
debounceMs: number = 300,
1733
) {
1834
const [state, setState] = useState<CompletionState>({
1935
completions: {},
2036
loading: {},
21-
error: {},
2237
});
2338

39+
const abortControllerRef = useRef<AbortController | null>(null);
40+
41+
const cleanup = useCallback(() => {
42+
if (abortControllerRef.current) {
43+
abortControllerRef.current.abort();
44+
abortControllerRef.current = null;
45+
}
46+
}, []);
47+
48+
// Cleanup on unmount
49+
useEffect(() => {
50+
return cleanup;
51+
}, [cleanup]);
52+
2453
const clearCompletions = useCallback(() => {
54+
cleanup();
2555
setState({
2656
completions: {},
2757
loading: {},
28-
error: {},
2958
});
30-
}, []);
59+
}, [cleanup]);
3160

3261
const requestCompletions = useCallback(
33-
async (ref: ResourceReference | PromptReference, argName: string, value: string) => {
34-
if (!completionsSupported) {
35-
return;
36-
}
62+
debounce(
63+
async (
64+
ref: ResourceReference | PromptReference,
65+
argName: string,
66+
value: string,
67+
) => {
68+
if (!completionsSupported) {
69+
return;
70+
}
3771

38-
setState((prev) => ({
39-
...prev,
40-
loading: { ...prev.loading, [argName]: true },
41-
error: { ...prev.error, [argName]: null },
42-
}));
72+
cleanup();
73+
74+
const abortController = new AbortController();
75+
abortControllerRef.current = abortController;
4376

44-
try {
45-
const values = await handleCompletion(ref, argName, value);
46-
setState((prev) => ({
47-
...prev,
48-
completions: { ...prev.completions, [argName]: values },
49-
loading: { ...prev.loading, [argName]: false },
50-
}));
51-
} catch (err) {
52-
const error = err instanceof Error ? err.message : String(err);
5377
setState((prev) => ({
5478
...prev,
55-
loading: { ...prev.loading, [argName]: false },
56-
error: { ...prev.error, [argName]: error },
79+
loading: { ...prev.loading, [argName]: true },
5780
}));
58-
}
59-
},
60-
[handleCompletion, completionsSupported],
81+
82+
try {
83+
const values = await handleCompletion(
84+
ref,
85+
argName,
86+
value,
87+
abortController.signal,
88+
);
89+
90+
if (!abortController.signal.aborted) {
91+
setState((prev) => ({
92+
...prev,
93+
completions: { ...prev.completions, [argName]: values },
94+
loading: { ...prev.loading, [argName]: false },
95+
}));
96+
}
97+
} catch (err) {
98+
if (!abortController.signal.aborted) {
99+
setState((prev) => ({
100+
...prev,
101+
loading: { ...prev.loading, [argName]: false },
102+
}));
103+
}
104+
} finally {
105+
if (abortControllerRef.current === abortController) {
106+
abortControllerRef.current = null;
107+
}
108+
}
109+
},
110+
debounceMs,
111+
),
112+
[handleCompletion, completionsSupported, cleanup, debounceMs],
61113
);
62114

63115
// Clear completions when support status changes

client/src/lib/hooks/useConnection.ts

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
ResourceReference,
1717
McpError,
1818
CompleteResultSchema,
19+
ErrorCode,
1920
} from "@modelcontextprotocol/sdk/types.js";
2021
import { useState } from "react";
2122
import { toast } from "react-toastify";
@@ -43,6 +44,7 @@ interface UseConnectionOptions {
4344
interface RequestOptions {
4445
signal?: AbortSignal;
4546
timeout?: number;
47+
suppressToast?: boolean;
4648
}
4749

4850
export function useConnection({
@@ -111,18 +113,10 @@ export function useConnection({
111113

112114
return response;
113115
} catch (e: unknown) {
114-
// Check for Method not found error specifically for completions
115-
if (
116-
request.method === "completion/complete" &&
117-
e instanceof McpError &&
118-
e.code === -32601
119-
) {
120-
setCompletionsSupported(false);
121-
return { completion: { values: [] } } as z.output<T>;
116+
if (!options?.suppressToast) {
117+
const errorString = (e as Error).message ?? String(e);
118+
toast.error(errorString);
122119
}
123-
124-
const errorString = (e as Error).message ?? String(e);
125-
toast.error(errorString);
126120
throw e;
127121
}
128122
};
@@ -151,32 +145,40 @@ export function useConnection({
151145
try {
152146
const response = await makeRequest(request, CompleteResultSchema, {
153147
signal,
148+
suppressToast: true,
154149
});
155150
return response?.completion.values || [];
156151
} catch (e: unknown) {
157-
const errorMessage = e instanceof Error ? e.message : String(e);
158-
pushHistory(request, { error: errorMessage });
159-
160-
if (e instanceof McpError && e.code === -32601) {
152+
// Disable completions silently if the server doesn't support them.
153+
// See https://github.com/modelcontextprotocol/specification/discussions/122
154+
if (e instanceof McpError && e.code === ErrorCode.MethodNotFound) {
161155
setCompletionsSupported(false);
162156
return [];
163157
}
164158

165-
toast.error(errorMessage);
159+
// Unexpected errors - show toast and rethrow
160+
toast.error(e instanceof Error ? e.message : String(e));
166161
throw e;
167162
}
168163
};
169164

170165
const sendNotification = async (notification: ClientNotification) => {
171166
if (!mcpClient) {
172-
throw new Error("MCP client not connected");
167+
const error = new Error("MCP client not connected");
168+
toast.error(error.message);
169+
throw error;
173170
}
174171

175172
try {
176173
await mcpClient.notification(notification);
174+
// Log successful notifications
177175
pushHistory(notification);
178176
} catch (e: unknown) {
179-
toast.error((e as Error).message ?? String(e));
177+
if (e instanceof McpError) {
178+
// Log MCP protocol errors
179+
pushHistory(notification, { error: e.message });
180+
}
181+
toast.error(e instanceof Error ? e.message : String(e));
180182
throw e;
181183
}
182184
};

0 commit comments

Comments
 (0)