Skip to content

Commit 5c782ad

Browse files
cliffhallclaudeevalstate
authored
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 86f6d09. * 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>
1 parent 10f4297 commit 5c782ad

7 files changed

Lines changed: 400 additions & 18 deletions

File tree

client/src/App.tsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -439,15 +439,27 @@ const App = () => {
439439
onElicitationRequest: (request, resolve) => {
440440
const currentTab = lastToolCallOriginTabRef.current;
441441

442+
const requestId = nextRequestId.current++;
443+
const requestData =
444+
request.params.mode === "url"
445+
? {
446+
id: requestId,
447+
mode: "url" as const,
448+
message: request.params.message,
449+
url: request.params.url,
450+
elicitationId: request.params.elicitationId,
451+
}
452+
: {
453+
id: requestId,
454+
message: request.params.message,
455+
requestedSchema: request.params.requestedSchema,
456+
};
457+
442458
setPendingElicitationRequests((prev) => [
443459
...prev,
444460
{
445-
id: nextRequestId.current++,
446-
request: {
447-
id: nextRequestId.current,
448-
message: request.params.message,
449-
requestedSchema: request.params.requestedSchema,
450-
},
461+
id: requestId,
462+
request: requestData,
451463
originatingTab: currentTab,
452464
resolve,
453465
decline: (error: Error) => {

client/src/components/ElicitationRequest.tsx

Lines changed: 140 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
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";
614
import { generateDefaultValue } from "@/utils/schemaUtils";
715
import {
816
PendingElicitationRequest,
917
ElicitationResponse,
18+
ElicitationFormRequestData,
1019
} from "./ElicitationTab";
1120
import Ajv from "ajv";
1221

@@ -21,12 +30,134 @@ const ElicitationRequest = ({
2130
}: ElicitationRequestProps) => {
2231
const [formData, setFormData] = useState<JsonValue>({});
2332
const [validationError, setValidationError] = useState<string | null>(null);
33+
const [showUrlConfirm, setShowUrlConfirm] = useState(false);
34+
35+
const requestData = request.request;
36+
const isUrlMode = requestData.mode === "url";
2437

2538
useEffect(() => {
26-
const defaultValue = generateDefaultValue(request.request.requestedSchema);
39+
if (isUrlMode) return;
40+
const defaultValue = generateDefaultValue(
41+
(requestData as ElicitationFormRequestData).requestedSchema,
42+
);
2743
setFormData(defaultValue);
2844
setValidationError(null);
29-
}, [request.request.requestedSchema]);
45+
}, [isUrlMode, requestData]);
46+
47+
if (isUrlMode) {
48+
// Per MCP SEP-1036 security requirements, only HTTPS URLs may be opened.
49+
// Validate the server-supplied URL before exposing the "Open URL" affordance
50+
// so non-https schemes (http:, file:, javascript:, data:) and malformed URLs
51+
// are refused rather than handed to window.open.
52+
let parsedUrl: URL | null = null;
53+
let urlError: string | null = null;
54+
try {
55+
parsedUrl = new URL(requestData.url);
56+
if (parsedUrl.protocol !== "https:") {
57+
urlError = `Refusing to open this URL: only https: URLs are permitted for URL elicitation (received scheme "${parsedUrl.protocol}").`;
58+
parsedUrl = null;
59+
}
60+
} catch {
61+
urlError = `The server provided a malformed URL: ${requestData.url}`;
62+
}
63+
64+
const handleConfirmOpen = () => {
65+
if (!parsedUrl) return;
66+
window.open(parsedUrl.href, "_blank", "noopener,noreferrer");
67+
setShowUrlConfirm(false);
68+
};
69+
70+
return (
71+
<div
72+
data-testid="elicitation-request"
73+
className="flex flex-col gap-4 p-4 border rounded-lg"
74+
>
75+
<div className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
76+
<div className="space-y-2">
77+
<h4 className="font-semibold">URL Request</h4>
78+
<p className="text-sm">{requestData.message}</p>
79+
</div>
80+
</div>
81+
{urlError && (
82+
<div
83+
data-testid="url-error"
84+
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"
85+
>
86+
<strong>Invalid URL:</strong> {urlError}
87+
</div>
88+
)}
89+
<div className="flex space-x-2">
90+
<Button
91+
type="button"
92+
disabled={!!urlError}
93+
onClick={() => setShowUrlConfirm(true)}
94+
>
95+
Open URL
96+
</Button>
97+
<Button
98+
type="button"
99+
onClick={() => onResolve(request.id, { action: "accept" })}
100+
>
101+
Accept
102+
</Button>
103+
<Button
104+
type="button"
105+
variant="outline"
106+
onClick={() => onResolve(request.id, { action: "decline" })}
107+
>
108+
Decline
109+
</Button>
110+
<Button
111+
type="button"
112+
variant="outline"
113+
onClick={() => onResolve(request.id, { action: "cancel" })}
114+
>
115+
Cancel
116+
</Button>
117+
</div>
118+
119+
<Dialog open={showUrlConfirm} onOpenChange={setShowUrlConfirm}>
120+
<DialogContent>
121+
<DialogHeader>
122+
<DialogTitle>Open External URL</DialogTitle>
123+
<DialogDescription>
124+
The server is requesting you visit the following URL:
125+
</DialogDescription>
126+
</DialogHeader>
127+
{parsedUrl && (
128+
<p className="text-sm">
129+
Destination host:{" "}
130+
<span
131+
data-testid="url-confirm-host"
132+
className="font-mono font-semibold break-all"
133+
>
134+
{parsedUrl.host}
135+
</span>
136+
</p>
137+
)}
138+
<p
139+
data-testid="url-confirm-text"
140+
className="text-sm font-mono bg-gray-100 dark:bg-gray-800 p-3 rounded break-all select-all"
141+
>
142+
{requestData.url}
143+
</p>
144+
<DialogFooter>
145+
<Button
146+
variant="outline"
147+
onClick={() => setShowUrlConfirm(false)}
148+
>
149+
Cancel
150+
</Button>
151+
<Button onClick={handleConfirmOpen}>Open</Button>
152+
</DialogFooter>
153+
</DialogContent>
154+
</Dialog>
155+
</div>
156+
);
157+
}
158+
159+
// After URL-mode early return, requestData is guaranteed to be form mode
160+
const formRequest = requestData as ElicitationFormRequestData;
30161

31162
const validateEmailFormat = (email: string): boolean => {
32163
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -75,12 +206,12 @@ const ElicitationRequest = ({
75206

76207
const handleAccept = () => {
77208
try {
78-
if (!validateFormData(formData, request.request.requestedSchema)) {
209+
if (!validateFormData(formData, formRequest.requestedSchema)) {
79210
return;
80211
}
81212

82213
const ajv = new Ajv();
83-
const validate = ajv.compile(request.request.requestedSchema);
214+
const validate = ajv.compile(formRequest.requestedSchema);
84215
const isValid = validate(formData);
85216

86217
if (!isValid) {
@@ -109,8 +240,8 @@ const ElicitationRequest = ({
109240
};
110241

111242
const schemaTitle =
112-
request.request.requestedSchema.title || "Information Request";
113-
const schemaDescription = request.request.requestedSchema.description;
243+
formRequest.requestedSchema.title || "Information Request";
244+
const schemaDescription = formRequest.requestedSchema.description;
114245

115246
return (
116247
<div
@@ -120,14 +251,14 @@ const ElicitationRequest = ({
120251
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
121252
<div className="space-y-2">
122253
<h4 className="font-semibold">{schemaTitle}</h4>
123-
<p className="text-sm">{request.request.message}</p>
254+
<p className="text-sm">{formRequest.message}</p>
124255
{schemaDescription && (
125256
<p className="text-xs text-muted-foreground">{schemaDescription}</p>
126257
)}
127258
<div className="mt-2">
128259
<h5 className="text-xs font-medium mb-1">Request Schema:</h5>
129260
<JsonView
130-
data={JSON.stringify(request.request.requestedSchema, null, 2)}
261+
data={JSON.stringify(formRequest.requestedSchema, null, 2)}
131262
/>
132263
</div>
133264
</div>
@@ -137,7 +268,7 @@ const ElicitationRequest = ({
137268
<div className="space-y-2">
138269
<h4 className="font-medium">Response Form</h4>
139270
<DynamicJsonForm
140-
schema={request.request.requestedSchema}
271+
schema={formRequest.requestedSchema}
141272
value={formData}
142273
onChange={(newValue: JsonValue) => {
143274
setFormData(newValue);

client/src/components/ElicitationTab.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,26 @@ import { TabsContent } from "@/components/ui/tabs";
33
import { JsonSchemaType } from "@/utils/jsonUtils";
44
import ElicitationRequest from "./ElicitationRequest";
55

6-
export interface ElicitationRequestData {
6+
interface ElicitationRequestBase {
77
id: number;
88
message: string;
9+
}
10+
11+
export interface ElicitationFormRequestData extends ElicitationRequestBase {
12+
mode?: "form";
913
requestedSchema: JsonSchemaType;
1014
}
1115

16+
export interface ElicitationUrlRequestData extends ElicitationRequestBase {
17+
mode: "url";
18+
url: string;
19+
elicitationId: string;
20+
}
21+
22+
export type ElicitationRequestData =
23+
| ElicitationFormRequestData
24+
| ElicitationUrlRequestData;
25+
1226
export interface ElicitationResponse {
1327
action: "accept" | "decline" | "cancel";
1428
content?: Record<string, unknown>;

0 commit comments

Comments
 (0)