Skip to content

Commit 231b2c0

Browse files
cliffhallclaude
andcommitted
Live completion/complete suggestions on resource template inputs
Wires the server's `completions` capability through to the resource template form so each variable's input becomes an Autocomplete that fires `completion/complete` (debounced, 300ms) on every keystroke and surfaces the returned values as a dropdown — mirroring v1's behavior. - core/mcp/inspectorClientProtocol.ts: surface getCompletions on the protocol so non-runtime callers (state managers, hooks, tests) can depend on it. FakeInspectorClient gains a vi.fn-backed stub. - ResourceTemplatePanel: accepts onCompleteArgument + completionsSupported. When both are present it renders Mantine Autocomplete instead of TextInput, debounces keystrokes via per-arg timers, aborts in-flight requests on the next keystroke, and disables client-side filtering (the server already filtered for the typed prefix). - ResourcesScreen / InspectorView: thread the props through; the screen-level callback re-injects the active template's URI as the `ref: "ref/resource"` so the panel-level callback stays ref-free. - App.tsx: wires onCompleteArgument to inspectorClient.getCompletions and derives completionsSupported from `capabilities?.completions`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ed85494 commit 231b2c0

7 files changed

Lines changed: 307 additions & 14 deletions

File tree

clients/web/src/App.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,27 @@ function App() {
508508
[inspectorClient],
509509
);
510510

511+
const onCompleteArgument = useCallback(
512+
async (
513+
ref:
514+
| { type: "ref/resource"; uri: string }
515+
| { type: "ref/prompt"; name: string },
516+
argumentName: string,
517+
argumentValue: string,
518+
context: Record<string, string>,
519+
): Promise<string[]> => {
520+
if (!inspectorClient) return [];
521+
const result = await inspectorClient.getCompletions(
522+
ref,
523+
argumentName,
524+
argumentValue,
525+
context,
526+
);
527+
return result.values;
528+
},
529+
[inspectorClient],
530+
);
531+
511532
const onCancelTask = useCallback(
512533
(taskId: string) => {
513534
if (!inspectorClient) return;
@@ -630,6 +651,8 @@ function App() {
630651
onSubscribeResource={onSubscribeResource}
631652
onUnsubscribeResource={onUnsubscribeResource}
632653
onRefreshResources={onRefreshResources}
654+
onCompleteArgument={onCompleteArgument}
655+
completionsSupported={capabilities?.completions !== undefined}
633656
onCancelTask={onCancelTask}
634657
onClearCompletedTasks={todoNoop}
635658
onRefreshTasks={onRefreshTasks}

clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,95 @@ describe("ResourceTemplatePanel", () => {
137137
screen.getByRole("button", { name: "Read Resource" }),
138138
).not.toBeDisabled();
139139
});
140+
141+
describe("completions", () => {
142+
it("calls onCompleteArgument (debounced) and surfaces values when supported", async () => {
143+
const user = userEvent.setup();
144+
const onCompleteArgument = vi
145+
.fn<
146+
(
147+
argName: string,
148+
value: string,
149+
context: Record<string, string>,
150+
) => Promise<string[]>
151+
>()
152+
.mockResolvedValue(["alpha", "alphabet"]);
153+
154+
renderWithMantine(
155+
<ResourceTemplatePanel
156+
template={singleVarTemplate}
157+
onReadResource={vi.fn()}
158+
completionsSupported
159+
onCompleteArgument={onCompleteArgument}
160+
/>,
161+
);
162+
163+
await user.type(screen.getByRole("textbox", { name: "userId" }), "al");
164+
// Wait past the 300ms debounce.
165+
await new Promise((r) => setTimeout(r, 400));
166+
expect(onCompleteArgument).toHaveBeenCalledTimes(1);
167+
expect(onCompleteArgument).toHaveBeenCalledWith("userId", "al", {});
168+
169+
// Server-returned values surface in the Autocomplete dropdown.
170+
expect(await screen.findByText("alpha")).toBeInTheDocument();
171+
expect(screen.getByText("alphabet")).toBeInTheDocument();
172+
});
173+
174+
it("passes sibling variables as completion context", async () => {
175+
const user = userEvent.setup();
176+
const onCompleteArgument = vi
177+
.fn<
178+
(
179+
argName: string,
180+
value: string,
181+
context: Record<string, string>,
182+
) => Promise<string[]>
183+
>()
184+
.mockResolvedValue([]);
185+
186+
renderWithMantine(
187+
<ResourceTemplatePanel
188+
template={titledTemplate}
189+
onReadResource={vi.fn()}
190+
completionsSupported
191+
onCompleteArgument={onCompleteArgument}
192+
/>,
193+
);
194+
195+
await user.type(
196+
screen.getByRole("textbox", { name: "tableName" }),
197+
"users",
198+
);
199+
await new Promise((r) => setTimeout(r, 400));
200+
// The completing arg ("tableName") is excluded from context; only
201+
// the other variables ride along.
202+
expect(onCompleteArgument).toHaveBeenLastCalledWith(
203+
"tableName",
204+
"users",
205+
{ rowId: "" },
206+
);
207+
208+
await user.type(screen.getByRole("textbox", { name: "rowId" }), "42");
209+
await new Promise((r) => setTimeout(r, 400));
210+
expect(onCompleteArgument).toHaveBeenLastCalledWith("rowId", "42", {
211+
tableName: "users",
212+
});
213+
});
214+
215+
it("does not call onCompleteArgument when completions are unsupported", async () => {
216+
const user = userEvent.setup();
217+
const onCompleteArgument = vi.fn();
218+
renderWithMantine(
219+
<ResourceTemplatePanel
220+
template={singleVarTemplate}
221+
onReadResource={vi.fn()}
222+
completionsSupported={false}
223+
onCompleteArgument={onCompleteArgument}
224+
/>,
225+
);
226+
await user.type(screen.getByLabelText("userId"), "ab");
227+
await new Promise((r) => setTimeout(r, 400));
228+
expect(onCompleteArgument).not.toHaveBeenCalled();
229+
});
230+
});
140231
});

clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx

Lines changed: 129 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,41 @@
1-
import { useState, useMemo } from "react";
2-
import { Button, Group, Stack, Text, TextInput, Title } from "@mantine/core";
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2+
import {
3+
Autocomplete,
4+
Button,
5+
Group,
6+
Stack,
7+
Text,
8+
TextInput,
9+
Title,
10+
} from "@mantine/core";
311
import type { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js";
412
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
513
import { CopyButton } from "../../elements/CopyButton/CopyButton";
614

715
export interface ResourceTemplatePanelProps {
816
template: ResourceTemplate;
917
onReadResource: (uri: string) => void;
18+
/**
19+
* When provided, each keystroke in a variable input dispatches a
20+
* (debounced) `completion/complete` request to the server. The
21+
* resolved values are surfaced as a dropdown via Mantine `Autocomplete`.
22+
* Wire to `InspectorClient.getCompletions` in the host App.
23+
*/
24+
onCompleteArgument?: (
25+
argumentName: string,
26+
argumentValue: string,
27+
context: Record<string, string>,
28+
) => Promise<string[]>;
29+
/**
30+
* Gates whether to render Autocomplete (with live completions) vs the
31+
* plain TextInput. Typically derived from the server's
32+
* `completions` capability.
33+
*/
34+
completionsSupported?: boolean;
1035
}
1136

37+
const COMPLETION_DEBOUNCE_MS = 300;
38+
1239
function parseVariableNames(uriTemplate: string): string[] {
1340
const names: string[] = [];
1441
const regex = /\{(\w+)\}/g;
@@ -69,6 +96,8 @@ const AnnotationGroup = Group.withProps({
6996
export function ResourceTemplatePanel({
7097
template,
7198
onReadResource,
99+
onCompleteArgument,
100+
completionsSupported = false,
72101
}: ResourceTemplatePanelProps) {
73102
const { name, title, uriTemplate, description, annotations } = template;
74103

@@ -80,9 +109,79 @@ export function ResourceTemplatePanel({
80109
const [variables, setVariables] = useState<Record<string, string>>(() =>
81110
Object.fromEntries(variableNames.map((n) => [n, ""])),
82111
);
112+
const [completions, setCompletions] = useState<Record<string, string[]>>({});
113+
114+
// Reset state when the user switches to a different template.
115+
useEffect(() => {
116+
setVariables(Object.fromEntries(variableNames.map((n) => [n, ""])));
117+
setCompletions({});
118+
}, [uriTemplate, variableNames]);
119+
120+
// Latest in-flight controller per argument, so a faster keystroke can
121+
// abort an outstanding completion request and the late response can't
122+
// overwrite the fresh one.
123+
const requestsRef = useRef<Map<string, AbortController>>(new Map());
124+
// Debounce timer per argument so we don't spam the server on every key.
125+
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
126+
new Map(),
127+
);
128+
129+
// Drop pending timers / abort in-flight requests on unmount.
130+
useEffect(() => {
131+
const timers = timersRef.current;
132+
const requests = requestsRef.current;
133+
return () => {
134+
for (const t of timers.values()) clearTimeout(t);
135+
timers.clear();
136+
for (const c of requests.values()) c.abort();
137+
requests.clear();
138+
};
139+
}, []);
140+
141+
const useAutocomplete = completionsSupported && !!onCompleteArgument;
142+
143+
const runCompletion = useCallback(
144+
async (varName: string, value: string, context: Record<string, string>) => {
145+
if (!onCompleteArgument) return;
146+
requestsRef.current.get(varName)?.abort();
147+
const controller = new AbortController();
148+
requestsRef.current.set(varName, controller);
149+
try {
150+
const values = await onCompleteArgument(varName, value, context);
151+
if (controller.signal.aborted) return;
152+
setCompletions((prev) => ({ ...prev, [varName]: values }));
153+
} catch {
154+
if (!controller.signal.aborted) {
155+
setCompletions((prev) => ({ ...prev, [varName]: [] }));
156+
}
157+
} finally {
158+
if (requestsRef.current.get(varName) === controller) {
159+
requestsRef.current.delete(varName);
160+
}
161+
}
162+
},
163+
[onCompleteArgument],
164+
);
83165

84166
function handleVariableChange(varName: string, value: string) {
85-
setVariables((prev) => ({ ...prev, [varName]: value }));
167+
setVariables((prev) => {
168+
const next = { ...prev, [varName]: value };
169+
if (useAutocomplete) {
170+
// Schedule a debounced completion call. The `context` carries the
171+
// other variables' current values so the server can disambiguate
172+
// when one variable depends on another.
173+
const context: Record<string, string> = { ...next };
174+
delete context[varName];
175+
const existing = timersRef.current.get(varName);
176+
if (existing) clearTimeout(existing);
177+
const timer = setTimeout(() => {
178+
timersRef.current.delete(varName);
179+
void runCompletion(varName, value, context);
180+
}, COMPLETION_DEBOUNCE_MS);
181+
timersRef.current.set(varName, timer);
182+
}
183+
return next;
184+
});
86185
}
87186

88187
const canSubmit = variableNames.every((n) => variables[n]?.length > 0);
@@ -104,17 +203,33 @@ export function ResourceTemplatePanel({
104203
</HeaderRow>
105204
{description && <DescriptionText>{description}</DescriptionText>}
106205
<Stack gap="sm">
107-
{variableNames.map((varName) => (
108-
<TextInput
109-
key={varName}
110-
label={varName}
111-
placeholder={`Enter ${varName}`}
112-
value={variables[varName] ?? ""}
113-
onChange={(e) =>
114-
handleVariableChange(varName, e.currentTarget.value)
115-
}
116-
/>
117-
))}
206+
{variableNames.map((varName) =>
207+
useAutocomplete ? (
208+
<Autocomplete
209+
key={varName}
210+
label={varName}
211+
placeholder={`Enter ${varName}`}
212+
value={variables[varName] ?? ""}
213+
data={completions[varName] ?? []}
214+
// The server already filtered the values for the typed
215+
// prefix; passing options through verbatim avoids hiding
216+
// valid suggestions when the input is empty or doesn't
217+
// substring-match what the server returned.
218+
filter={({ options }) => options}
219+
onChange={(value) => handleVariableChange(varName, value)}
220+
/>
221+
) : (
222+
<TextInput
223+
key={varName}
224+
label={varName}
225+
placeholder={`Enter ${varName}`}
226+
value={variables[varName] ?? ""}
227+
onChange={(e) =>
228+
handleVariableChange(varName, e.currentTarget.value)
229+
}
230+
/>
231+
),
232+
)}
118233
</Stack>
119234
<FooterRow>
120235
<AnnotationGroup>

clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,19 @@ export interface ResourcesScreenProps {
2525
subscriptions: InspectorResourceSubscription[];
2626
readState?: ReadResourceState;
2727
listChanged: boolean;
28+
completionsSupported?: boolean;
2829
onRefreshList: () => void;
2930
onReadResource: (uri: string) => void;
3031
onSubscribeResource: (uri: string) => void;
3132
onUnsubscribeResource: (uri: string) => void;
33+
onCompleteArgument?: (
34+
ref:
35+
| { type: "ref/resource"; uri: string }
36+
| { type: "ref/prompt"; name: string },
37+
argumentName: string,
38+
argumentValue: string,
39+
context: Record<string, string>,
40+
) => Promise<string[]>;
3241
}
3342

3443
const ScreenLayout = Flex.withProps({
@@ -88,10 +97,12 @@ export function ResourcesScreen({
8897
subscriptions,
8998
readState,
9099
listChanged,
100+
completionsSupported,
91101
onRefreshList,
92102
onReadResource,
93103
onSubscribeResource,
94104
onUnsubscribeResource,
105+
onCompleteArgument,
95106
}: ResourcesScreenProps) {
96107
const [selectedResourceUri, setSelectedResourceUri] = useState<
97108
string | undefined
@@ -210,6 +221,21 @@ export function ResourcesScreen({
210221
<ResourceTemplatePanel
211222
template={selectedTemplate}
212223
onReadResource={handleReadResource}
224+
completionsSupported={completionsSupported}
225+
onCompleteArgument={
226+
onCompleteArgument
227+
? (argName, value, context) =>
228+
onCompleteArgument(
229+
{
230+
type: "ref/resource",
231+
uri: selectedTemplate.uriTemplate,
232+
},
233+
argName,
234+
value,
235+
context,
236+
)
237+
: undefined
238+
}
213239
/>
214240
</PreviewCard>
215241
</PreviewPane>

0 commit comments

Comments
 (0)