Skip to content

Commit 04646d7

Browse files
cliffhallclaude
andcommitted
feat: add consent dialog for URL elicitation and separate Accept button
Replace the clickable URL link with a consent dialog that displays the URL as plain text when "Open URL" is clicked, letting the user confirm before navigating. Add a separate "Accept" button that resolves the elicitation without the content field, per the MCP spec. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 548a88a commit 04646d7

3 files changed

Lines changed: 106 additions & 24 deletions

File tree

client/src/components/ElicitationRequest.tsx

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { useState, useEffect } from "react";
22
import { Button } from "@/components/ui/button";
3+
import {
4+
Dialog,
5+
DialogContent,
6+
DialogHeader,
7+
DialogTitle,
8+
DialogDescription,
9+
DialogFooter,
10+
} from "@/components/ui/dialog";
311
import DynamicJsonForm from "./DynamicJsonForm";
412
import JsonView from "./JsonView";
513
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
@@ -22,6 +30,7 @@ const ElicitationRequest = ({
2230
}: ElicitationRequestProps) => {
2331
const [formData, setFormData] = useState<JsonValue>({});
2432
const [validationError, setValidationError] = useState<string | null>(null);
33+
const [showUrlConfirm, setShowUrlConfirm] = useState(false);
2534

2635
const requestData = request.request;
2736
const isUrlMode = requestData.mode === "url";
@@ -36,9 +45,9 @@ const ElicitationRequest = ({
3645
}, [isUrlMode, requestData]);
3746

3847
if (isUrlMode) {
39-
const handleOpenUrl = () => {
48+
const handleConfirmOpen = () => {
4049
window.open(requestData.url, "_blank", "noopener,noreferrer");
41-
onResolve(request.id, { action: "accept" });
50+
setShowUrlConfirm(false);
4251
};
4352

4453
return (
@@ -50,20 +59,18 @@ const ElicitationRequest = ({
5059
<div className="space-y-2">
5160
<h4 className="font-semibold">URL Request</h4>
5261
<p className="text-sm">{requestData.message}</p>
53-
<a
54-
href={requestData.url}
55-
target="_blank"
56-
rel="noopener noreferrer"
57-
className="text-sm text-blue-600 dark:text-blue-400 underline break-all"
58-
>
59-
{requestData.url}
60-
</a>
6162
</div>
6263
</div>
6364
<div className="flex space-x-2">
64-
<Button type="button" onClick={handleOpenUrl}>
65+
<Button type="button" onClick={() => setShowUrlConfirm(true)}>
6566
Open URL
6667
</Button>
68+
<Button
69+
type="button"
70+
onClick={() => onResolve(request.id, { action: "accept" })}
71+
>
72+
Accept
73+
</Button>
6774
<Button
6875
type="button"
6976
variant="outline"
@@ -79,6 +86,32 @@ const ElicitationRequest = ({
7986
Cancel
8087
</Button>
8188
</div>
89+
90+
<Dialog open={showUrlConfirm} onOpenChange={setShowUrlConfirm}>
91+
<DialogContent>
92+
<DialogHeader>
93+
<DialogTitle>Open External URL</DialogTitle>
94+
<DialogDescription>
95+
The server is requesting you visit the following URL:
96+
</DialogDescription>
97+
</DialogHeader>
98+
<p
99+
data-testid="url-confirm-text"
100+
className="text-sm font-mono bg-gray-100 dark:bg-gray-800 p-3 rounded break-all select-all"
101+
>
102+
{requestData.url}
103+
</p>
104+
<DialogFooter>
105+
<Button
106+
variant="outline"
107+
onClick={() => setShowUrlConfirm(false)}
108+
>
109+
Cancel
110+
</Button>
111+
<Button onClick={handleConfirmOpen}>Open</Button>
112+
</DialogFooter>
113+
</DialogContent>
114+
</Dialog>
82115
</div>
83116
);
84117
}

client/src/components/__tests__/ElicitationRequest.test.tsx

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -212,25 +212,23 @@ describe("ElicitationRequest", () => {
212212
},
213213
});
214214

215-
it("should render URL mode request with link and message", () => {
215+
it("should render URL mode request with message but no clickable link", () => {
216216
renderElicitationRequest(createUrlRequest());
217217
expect(screen.getByTestId("elicitation-request")).toBeInTheDocument();
218218
expect(
219219
screen.getByText("Please complete authentication"),
220220
).toBeInTheDocument();
221-
const link = screen.getByRole("link", {
222-
name: "https://example.com/auth",
223-
});
224-
expect(link).toHaveAttribute("href", "https://example.com/auth");
225-
expect(link).toHaveAttribute("target", "_blank");
226-
expect(link).toHaveAttribute("rel", "noopener noreferrer");
221+
expect(screen.queryByRole("link")).not.toBeInTheDocument();
227222
});
228223

229-
it("should render Open URL, Decline, and Cancel buttons", () => {
224+
it("should render Open URL, Accept, Decline, and Cancel buttons", () => {
230225
renderElicitationRequest(createUrlRequest());
231226
expect(
232227
screen.getByRole("button", { name: /open url/i }),
233228
).toBeInTheDocument();
229+
expect(
230+
screen.getByRole("button", { name: /accept/i }),
231+
).toBeInTheDocument();
234232
expect(
235233
screen.getByRole("button", { name: /decline/i }),
236234
).toBeInTheDocument();
@@ -244,7 +242,20 @@ describe("ElicitationRequest", () => {
244242
expect(screen.queryByTestId("dynamic-json-form")).not.toBeInTheDocument();
245243
});
246244

247-
it("should call window.open and resolve with accept when Open URL is clicked", async () => {
245+
it("should show consent dialog with URL as text when Open URL is clicked", async () => {
246+
renderElicitationRequest(createUrlRequest());
247+
248+
await act(async () => {
249+
fireEvent.click(screen.getByRole("button", { name: /open url/i }));
250+
});
251+
252+
expect(screen.getByTestId("url-confirm-text")).toHaveTextContent(
253+
"https://example.com/auth",
254+
);
255+
expect(screen.getByText("Open External URL")).toBeInTheDocument();
256+
});
257+
258+
it("should open URL when confirmed in consent dialog", async () => {
248259
const windowOpenSpy = jest
249260
.spyOn(window, "open")
250261
.mockImplementation(() => null);
@@ -254,15 +265,55 @@ describe("ElicitationRequest", () => {
254265
fireEvent.click(screen.getByRole("button", { name: /open url/i }));
255266
});
256267

268+
await act(async () => {
269+
fireEvent.click(screen.getByRole("button", { name: /^open$/i }));
270+
});
271+
257272
expect(windowOpenSpy).toHaveBeenCalledWith(
258273
"https://example.com/auth",
259274
"_blank",
260275
"noopener,noreferrer",
261276
);
262-
expect(mockOnResolve).toHaveBeenCalledWith(2, { action: "accept" });
277+
expect(mockOnResolve).not.toHaveBeenCalled();
278+
windowOpenSpy.mockRestore();
279+
});
280+
281+
it("should close consent dialog without opening URL when cancelled", async () => {
282+
const windowOpenSpy = jest
283+
.spyOn(window, "open")
284+
.mockImplementation(() => null);
285+
renderElicitationRequest(createUrlRequest());
286+
287+
await act(async () => {
288+
fireEvent.click(screen.getByRole("button", { name: /open url/i }));
289+
});
290+
291+
expect(screen.getByTestId("url-confirm-text")).toBeInTheDocument();
292+
293+
await act(async () => {
294+
// The Cancel button inside the dialog
295+
const dialogButtons = screen.getAllByRole("button", {
296+
name: /cancel/i,
297+
});
298+
fireEvent.click(dialogButtons[dialogButtons.length - 1]);
299+
});
300+
301+
expect(windowOpenSpy).not.toHaveBeenCalled();
302+
expect(mockOnResolve).not.toHaveBeenCalled();
263303
windowOpenSpy.mockRestore();
264304
});
265305

306+
it("should resolve with accept and no content when Accept is clicked", async () => {
307+
renderElicitationRequest(createUrlRequest());
308+
309+
await act(async () => {
310+
fireEvent.click(screen.getByRole("button", { name: /accept/i }));
311+
});
312+
313+
expect(mockOnResolve).toHaveBeenCalledWith(2, { action: "accept" });
314+
expect(mockOnResolve.mock.calls[0][1]).not.toHaveProperty("content");
315+
});
316+
266317
it("should resolve with decline when Decline is clicked", async () => {
267318
renderElicitationRequest(createUrlRequest());
268319

client/src/components/__tests__/ElicitationTab.test.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,6 @@ describe("Elicitation tab", () => {
6363
]);
6464
expect(screen.getAllByTestId("elicitation-request").length).toBe(1);
6565
expect(screen.getByText("Please authenticate")).toBeTruthy();
66-
expect(
67-
screen.getByRole("link", { name: "https://example.com/auth" }),
68-
).toBeTruthy();
66+
expect(screen.getByRole("button", { name: /open url/i })).toBeTruthy();
6967
});
7068
});

0 commit comments

Comments
 (0)