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:
parent
10f429759f
commit
5c782ada7c
7 changed files with 400 additions and 18 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -544,7 +544,10 @@ describe("useConnection", () => {
|
|||
}),
|
||||
expect.objectContaining({
|
||||
capabilities: expect.objectContaining({
|
||||
elicitation: {},
|
||||
elicitation: {
|
||||
form: {},
|
||||
url: {},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue