feat: add URL-mode elicitation support (#1423)

* feat: add URL-mode elicitation support

The MCP spec (2025-11-25) added URL-mode elicitation alongside form mode,
allowing servers to direct users to external URLs for out-of-band
interactions (auth flows, payments, sensitive data). This implements
client-side support for rendering and handling URL-mode elicitation
requests, and announces the capability during initialization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 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>

* fix: preserve structured McpError data in request history

When a tool call returns an MCP error (e.g. -32042 URLElicitationRequired),
the catch block was only capturing error.message (a string), discarding the
structured error.code and error.data fields.

Now McpError instances push the full JSON-RPC error structure to history:
  { error: { code, message, data } }

This means the Response panel will show the complete error including
data.elicitations for -32042, rather than just a message string.

* Revert "fix: preserve structured McpError data in request history"

This reverts commit 86f6d093f6596d6a9a52b85df24b66fa2e354d07.

* fix: enforce HTTPS-only for URL elicitation per SEP-1036

Address Claude review on #1423:
- Validate the server-supplied URL before opening it; refuse non-https
  schemes (http/file/javascript/data) and malformed URLs, disabling the
  "Open URL" button and showing an error state instead of calling
  window.open (SEP-1036 URL Security Requirements, a spec MUST).
- Display the parsed destination host prominently in the consent dialog
  to surface look-alike/embedded-credential URLs.
- Document that elicitation/complete is surfaced via the notification
  panel and resolved manually (no auto-correlation), a conscious choice
  per the spec's MAY/SHOULD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: evalstate <1936278+evalstate@users.noreply.github.com>
This commit is contained in:
Cliff Hall 2026-06-03 21:09:38 -04:00 • committed by GitHub
commit 5c782ada7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 400 additions and 18 deletions

View file

@ -439,15 +439,27 @@ const App = () => {
onElicitationRequest: (request, resolve) => {
const currentTab = lastToolCallOriginTabRef.current;
const requestId = nextRequestId.current++;
const requestData =
request.params.mode === "url"
? {
id: requestId,
mode: "url" as const,
message: request.params.message,
url: request.params.url,
elicitationId: request.params.elicitationId,
}
: {
id: requestId,
message: request.params.message,
requestedSchema: request.params.requestedSchema,
};
setPendingElicitationRequests((prev) => [
...prev,
{
id: nextRequestId.current++,
request: {
id: nextRequestId.current,
message: request.params.message,
requestedSchema: request.params.requestedSchema,
},
id: requestId,
request: requestData,
originatingTab: currentTab,
resolve,
decline: (error: Error) => {

View file

@ -1,5 +1,13 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import DynamicJsonForm from "./DynamicJsonForm";
import JsonView from "./JsonView";
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
@ -7,6 +15,7 @@ import { generateDefaultValue } from "@/utils/schemaUtils";
import {
PendingElicitationRequest,
ElicitationResponse,
ElicitationFormRequestData,
} from "./ElicitationTab";
import Ajv from "ajv";
@ -21,12 +30,134 @@ const ElicitationRequest = ({
}: ElicitationRequestProps) => {
const [formData, setFormData] = useState<JsonValue>({});
const [validationError, setValidationError] = useState<string | null>(null);
const [showUrlConfirm, setShowUrlConfirm] = useState(false);
const requestData = request.request;
const isUrlMode = requestData.mode === "url";
useEffect(() => {
const defaultValue = generateDefaultValue(request.request.requestedSchema);
if (isUrlMode) return;
const defaultValue = generateDefaultValue(
(requestData as ElicitationFormRequestData).requestedSchema,
);
setFormData(defaultValue);
setValidationError(null);
}, [request.request.requestedSchema]);
}, [isUrlMode, requestData]);
if (isUrlMode) {
// Per MCP SEP-1036 security requirements, only HTTPS URLs may be opened.
// Validate the server-supplied URL before exposing the "Open URL" affordance
// so non-https schemes (http:, file:, javascript:, data:) and malformed URLs
// are refused rather than handed to window.open.
let parsedUrl: URL | null = null;
let urlError: string | null = null;
try {
parsedUrl = new URL(requestData.url);
if (parsedUrl.protocol !== "https:") {
urlError = `Refusing to open this URL: only https: URLs are permitted for URL elicitation (received scheme "${parsedUrl.protocol}").`;
parsedUrl = null;
}
} catch {
urlError = `The server provided a malformed URL: ${requestData.url}`;
}
const handleConfirmOpen = () => {
if (!parsedUrl) return;
window.open(parsedUrl.href, "_blank", "noopener,noreferrer");
setShowUrlConfirm(false);
};
return (
<div
data-testid="elicitation-request"
className="flex flex-col gap-4 p-4 border rounded-lg"
>
<div className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
<div className="space-y-2">
<h4 className="font-semibold">URL Request</h4>
<p className="text-sm">{requestData.message}</p>
</div>
</div>
{urlError && (
<div
data-testid="url-error"
className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md text-sm text-red-600 dark:text-red-400"
>
<strong>Invalid URL:</strong> {urlError}
</div>
)}
<div className="flex space-x-2">
<Button
type="button"
disabled={!!urlError}
onClick={() => setShowUrlConfirm(true)}
>
Open URL
</Button>
<Button
type="button"
onClick={() => onResolve(request.id, { action: "accept" })}
>
Accept
</Button>
<Button
type="button"
variant="outline"
onClick={() => onResolve(request.id, { action: "decline" })}
>
Decline
</Button>
<Button
type="button"
variant="outline"
onClick={() => onResolve(request.id, { action: "cancel" })}
>
Cancel
</Button>
</div>
<Dialog open={showUrlConfirm} onOpenChange={setShowUrlConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>Open External URL</DialogTitle>
<DialogDescription>
The server is requesting you visit the following URL:
</DialogDescription>
</DialogHeader>
{parsedUrl && (
<p className="text-sm">
Destination host:{" "}
<span
data-testid="url-confirm-host"
className="font-mono font-semibold break-all"
>
{parsedUrl.host}
</span>
</p>
)}
<p
data-testid="url-confirm-text"
className="text-sm font-mono bg-gray-100 dark:bg-gray-800 p-3 rounded break-all select-all"
>
{requestData.url}
</p>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowUrlConfirm(false)}
>
Cancel
</Button>
<Button onClick={handleConfirmOpen}>Open</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// After URL-mode early return, requestData is guaranteed to be form mode
const formRequest = requestData as ElicitationFormRequestData;
const validateEmailFormat = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@ -75,12 +206,12 @@ const ElicitationRequest = ({
const handleAccept = () => {
try {
if (!validateFormData(formData, request.request.requestedSchema)) {
if (!validateFormData(formData, formRequest.requestedSchema)) {
return;
}
const ajv = new Ajv();
const validate = ajv.compile(request.request.requestedSchema);
const validate = ajv.compile(formRequest.requestedSchema);
const isValid = validate(formData);
if (!isValid) {
@ -109,8 +240,8 @@ const ElicitationRequest = ({
};
const schemaTitle =
request.request.requestedSchema.title || "Information Request";
const schemaDescription = request.request.requestedSchema.description;
formRequest.requestedSchema.title || "Information Request";
const schemaDescription = formRequest.requestedSchema.description;
return (
<div
@ -120,14 +251,14 @@ const ElicitationRequest = ({
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
<div className="space-y-2">
<h4 className="font-semibold">{schemaTitle}</h4>
<p className="text-sm">{request.request.message}</p>
<p className="text-sm">{formRequest.message}</p>
{schemaDescription && (
<p className="text-xs text-muted-foreground">{schemaDescription}</p>
)}
<div className="mt-2">
<h5 className="text-xs font-medium mb-1">Request Schema:</h5>
<JsonView
data={JSON.stringify(request.request.requestedSchema, null, 2)}
data={JSON.stringify(formRequest.requestedSchema, null, 2)}
/>
</div>
</div>
@ -137,7 +268,7 @@ const ElicitationRequest = ({
<div className="space-y-2">
<h4 className="font-medium">Response Form</h4>
<DynamicJsonForm
schema={request.request.requestedSchema}
schema={formRequest.requestedSchema}
value={formData}
onChange={(newValue: JsonValue) => {
setFormData(newValue);

View file

@ -3,12 +3,26 @@ import { TabsContent } from "@/components/ui/tabs";
import { JsonSchemaType } from "@/utils/jsonUtils";
import ElicitationRequest from "./ElicitationRequest";
export interface ElicitationRequestData {
interface ElicitationRequestBase {
id: number;
message: string;
}
export interface ElicitationFormRequestData extends ElicitationRequestBase {
mode?: "form";
requestedSchema: JsonSchemaType;
}
export interface ElicitationUrlRequestData extends ElicitationRequestBase {
mode: "url";
url: string;
elicitationId: string;
}
export type ElicitationRequestData =
| ElicitationFormRequestData
| ElicitationUrlRequestData;
export interface ElicitationResponse {
action: "accept" | "decline" | "cancel";
content?: Record<string, unknown>;

View file

@ -199,4 +199,198 @@ describe("ElicitationRequest", () => {
);
});
});
describe("URL Mode", () => {
const createUrlRequest = (): PendingElicitationRequest => ({
id: 2,
request: {
id: 2,
mode: "url",
message: "Please complete authentication",
url: "https://example.com/auth",
elicitationId: "elicit-123",
},
});
it("should render URL mode request with message but no clickable link", () => {
renderElicitationRequest(createUrlRequest());
expect(screen.getByTestId("elicitation-request")).toBeInTheDocument();
expect(
screen.getByText("Please complete authentication"),
).toBeInTheDocument();
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
it("should render Open URL, Accept, Decline, and Cancel buttons", () => {
renderElicitationRequest(createUrlRequest());
expect(
screen.getByRole("button", { name: /open url/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /accept/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /decline/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /cancel/i }),
).toBeInTheDocument();
});
it("should not render DynamicJsonForm", () => {
renderElicitationRequest(createUrlRequest());
expect(screen.queryByTestId("dynamic-json-form")).not.toBeInTheDocument();
});
it("should show consent dialog with URL as text when Open URL is clicked", async () => {
renderElicitationRequest(createUrlRequest());
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /open url/i }));
});
expect(screen.getByTestId("url-confirm-text")).toHaveTextContent(
"https://example.com/auth",
);
expect(screen.getByText("Open External URL")).toBeInTheDocument();
});
it("should open URL when confirmed in consent dialog", async () => {
const windowOpenSpy = jest
.spyOn(window, "open")
.mockImplementation(() => null);
renderElicitationRequest(createUrlRequest());
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /open url/i }));
});
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /^open$/i }));
});
expect(windowOpenSpy).toHaveBeenCalledWith(
"https://example.com/auth",
"_blank",
"noopener,noreferrer",
);
expect(mockOnResolve).not.toHaveBeenCalled();
windowOpenSpy.mockRestore();
});
it("should close consent dialog without opening URL when cancelled", async () => {
const windowOpenSpy = jest
.spyOn(window, "open")
.mockImplementation(() => null);
renderElicitationRequest(createUrlRequest());
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /open url/i }));
});
expect(screen.getByTestId("url-confirm-text")).toBeInTheDocument();
await act(async () => {
// The Cancel button inside the dialog
const dialogButtons = screen.getAllByRole("button", {
name: /cancel/i,
});
fireEvent.click(dialogButtons[dialogButtons.length - 1]);
});
expect(windowOpenSpy).not.toHaveBeenCalled();
expect(mockOnResolve).not.toHaveBeenCalled();
windowOpenSpy.mockRestore();
});
it("should resolve with accept and no content when Accept is clicked", async () => {
renderElicitationRequest(createUrlRequest());
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /accept/i }));
});
expect(mockOnResolve).toHaveBeenCalledWith(2, { action: "accept" });
expect(mockOnResolve.mock.calls[0][1]).not.toHaveProperty("content");
});
it("should resolve with decline when Decline is clicked", async () => {
renderElicitationRequest(createUrlRequest());
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /decline/i }));
});
expect(mockOnResolve).toHaveBeenCalledWith(2, { action: "decline" });
});
it("should resolve with cancel when Cancel is clicked", async () => {
renderElicitationRequest(createUrlRequest());
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
});
expect(mockOnResolve).toHaveBeenCalledWith(2, { action: "cancel" });
});
it("should display the destination host in the consent dialog", async () => {
renderElicitationRequest(createUrlRequest());
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /open url/i }));
});
expect(screen.getByTestId("url-confirm-host")).toHaveTextContent(
"example.com",
);
});
it.each([
["http://example.com/auth", "http: (non-HTTPS)"],
["javascript:alert(1)", "javascript:"],
["file:///etc/passwd", "file:"],
["data:text/html,<script>alert(1)</script>", "data:"],
])(
"should refuse to open non-HTTPS URL %s and disable Open URL",
async (url) => {
const windowOpenSpy = jest
.spyOn(window, "open")
.mockImplementation(() => null);
renderElicitationRequest({
id: 2,
request: {
id: 2,
mode: "url",
message: "Please complete authentication",
url,
elicitationId: "elicit-123",
},
});
expect(screen.getByTestId("url-error")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /open url/i }),
).toBeDisabled();
expect(windowOpenSpy).not.toHaveBeenCalled();
windowOpenSpy.mockRestore();
},
);
it("should show an error and disable Open URL for a malformed URL", () => {
renderElicitationRequest({
id: 2,
request: {
id: 2,
mode: "url",
message: "Please complete authentication",
url: "not a url",
elicitationId: "elicit-123",
},
});
expect(screen.getByTestId("url-error")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /open url/i })).toBeDisabled();
});
});
});

View file

@ -47,4 +47,22 @@ describe("Elicitation tab", () => {
);
expect(screen.getAllByTestId("elicitation-request").length).toBe(3);
});
it("should render a URL-mode elicitation request", () => {
renderElicitationTab([
{
id: 1,
request: {
id: 1,
mode: "url",
message: "Please authenticate",
url: "https://example.com/auth",
elicitationId: "elicit-456",
},
},
]);
expect(screen.getAllByTestId("elicitation-request").length).toBe(1);
expect(screen.getByText("Please authenticate")).toBeTruthy();
expect(screen.getByRole("button", { name: /open url/i })).toBeTruthy();
});
});

View file

@ -544,7 +544,10 @@ describe("useConnection", () => {
}),
expect.objectContaining({
capabilities: expect.objectContaining({
elicitation: {},
elicitation: {
form: {},
url: {},
},
}),
}),
);

View file

@ -27,6 +27,7 @@ import {
ResourceListChangedNotificationSchema,
ToolListChangedNotificationSchema,
PromptListChangedNotificationSchema,
ElicitationCompleteNotificationSchema,
Progress,
LoggingLevel,
ElicitRequestSchema,
@ -447,7 +448,10 @@ export function useConnection({
const clientCapabilities = {
capabilities: {
sampling: {},
elicitation: {},
elicitation: {
form: {},
url: {},
},
roots: {
listChanged: true,
},
@ -749,6 +753,12 @@ export function useConnection({
ToolListChangedNotificationSchema,
PromptListChangedNotificationSchema,
TaskStatusNotificationSchema,
// SEP-1036: elicitation/complete is surfaced in the notification panel
// so the user can observe out-of-band completion. The Inspector does
// not auto-correlate the notification's elicitationId back to its
// pending request (a spec MAY); the user resolves the request manually
// via the Accept button (the spec SHOULD).
ElicitationCompleteNotificationSchema,
].forEach((notificationSchema) => {
client.setNotificationHandler(notificationSchema, onNotification);
});