Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 89 additions & 16 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
LoggingLevel,
Task,
GetTaskResultSchema,
McpError,
ErrorCode,
} from "@modelcontextprotocol/sdk/types.js";
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
import type {
Expand Down Expand Up @@ -252,12 +254,7 @@ const App = () => {
>
>([]);
const [pendingElicitationRequests, setPendingElicitationRequests] = useState<
Array<
PendingElicitationRequest & {
resolve: (response: ElicitationResponse) => void;
decline: (error: Error) => void;
}
>
Array<PendingElicitationRequest>
>([]);
const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false);

Expand Down Expand Up @@ -445,7 +442,10 @@ const App = () => {
request: {
id: nextRequestId.current,
message: request.params.message,
mode: request.params.mode || "form", // Default to form for backwards compatibility
requestedSchema: request.params.requestedSchema,
url: request.params.url,
elicitationId: request.params.elicitationId,
},
originatingTab: currentTab,
resolve,
Expand Down Expand Up @@ -1191,16 +1191,89 @@ const App = () => {
return directResult;
}
} catch (e) {
const toolResult: CompatibilityCallToolResult = {
content: [
{
type: "text",
text: (e as Error).message ?? String(e),
},
],
isError: true,
};
setToolResult(toolResult);
let toolResult: CompatibilityCallToolResult;
// Check if this is a URLElicitationRequiredError (error code -32042)
if (
e instanceof McpError &&
e.code === ErrorCode.UrlElicitationRequired
) {
// Extract elicitation info from error data
const errorData = e.data as {
elicitations?: Array<{
mode: "url";
elicitationId: string;
url: string;
message: string;
}>;
};

if (errorData?.elicitations && errorData.elicitations.length > 0) {
// Process each elicitation request
errorData.elicitations.forEach((elicitation) => {
setPendingElicitationRequests((prev) => [
...prev,
{
id: nextRequestId.current++,
request: {
id: nextRequestId.current,
mode: elicitation.mode,
message: elicitation.message,
url: elicitation.url,
elicitationId: elicitation.elicitationId,
},
originatingTab: "tools",
resolve: (result) => {
// After elicitation is resolved, user can retry the tool call
console.log("URL elicitation resolved:", result);
},
decline: (error: Error) => {
console.error("URL elicitation declined:", error);
},
},
]);
});

// Switch to elicitations tab
setActiveTab("elicitations");
window.location.hash = "elicitations";

// Show a message in the tool result that elicitation is required
toolResult = {
content: [
{
type: "text",
text: `This operation requires additional authorization. Please complete the elicitation request in the Elicitations tab.`,
},
],
isError: false,
};
setToolResult(toolResult);
} else {
// No elicitation data provided, show the error
toolResult = {
content: [
{
type: "text",
text: (e as Error).message ?? String(e),
},
],
isError: true,
};
setToolResult(toolResult);
}
} else {
// Regular error handling
toolResult = {
content: [
{
type: "text",
text: (e as Error).message ?? String(e),
},
],
isError: true,
};
setToolResult(toolResult);
}
// Clear validation errors - tool execution errors are shown in ToolResults
setErrors((prev) => ({ ...prev, tools: null }));
return toolResult;
Expand Down
197 changes: 159 additions & 38 deletions client/src/components/ElicitationRequest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
ElicitationResponse,
} from "./ElicitationTab";
import Ajv from "ajv";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ExternalLink } from "lucide-react";

export type ElicitationRequestProps = {
request: PendingElicitationRequest;
Expand All @@ -22,11 +24,18 @@ const ElicitationRequest = ({
const [formData, setFormData] = useState<JsonValue>({});
const [validationError, setValidationError] = useState<string | null>(null);

const requestedSchema =
request.request.mode === "form"
? request.request.requestedSchema
: undefined;

useEffect(() => {
const defaultValue = generateDefaultValue(request.request.requestedSchema);
setFormData(defaultValue);
setValidationError(null);
}, [request.request.requestedSchema]);
if (requestedSchema) {
const defaultValue = generateDefaultValue(requestedSchema);
setFormData(defaultValue);
setValidationError(null);
}
}, [requestedSchema]);

const validateEmailFormat = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
Expand Down Expand Up @@ -73,30 +82,62 @@ const ElicitationRequest = ({
return true;
};

const handleAccept = () => {
const validateUrl = (url: string): boolean => {
try {
if (!validateFormData(formData, request.request.requestedSchema)) {
return;
const parsedUrl = new URL(url);
// Only allow HTTPS URLs for security
if (parsedUrl.protocol !== "https:") {
setValidationError("Only HTTPS URLs are allowed for security reasons");
return false;
}
return true;
} catch {
setValidationError("Invalid URL format");
return false;
}
};

const handleAccept = () => {
if (request.request.mode === "url") {
// For URL mode, just accept and let the browser handle the URL
onResolve(request.id, { action: "accept" });
} else if (
request.request.mode === "form" &&
request.request.requestedSchema
) {
// For form mode, validate and submit the form data
try {
if (!validateFormData(formData, request.request.requestedSchema)) {
return;
}

const ajv = new Ajv();
const validate = ajv.compile(request.request.requestedSchema);
const isValid = validate(formData);

const ajv = new Ajv();
const validate = ajv.compile(request.request.requestedSchema);
const isValid = validate(formData);
if (!isValid) {
const errorMessage = ajv.errorsText(validate.errors);
setValidationError(errorMessage);
return;
}

if (!isValid) {
const errorMessage = ajv.errorsText(validate.errors);
setValidationError(errorMessage);
return;
onResolve(request.id, {
action: "accept",
content: formData as Record<string, unknown>,
});
} catch (error) {
setValidationError(
error instanceof Error ? error.message : "Validation failed",
);
}
}
};

onResolve(request.id, {
action: "accept",
content: formData as Record<string, unknown>,
});
} catch (error) {
setValidationError(
error instanceof Error ? error.message : "Validation failed",
);
const handleOpenUrl = () => {
if (request.request.mode === "url" && request.request.url) {
if (validateUrl(request.request.url)) {
window.open(request.request.url, "_blank", "noopener,noreferrer");
}
}
};

Expand All @@ -108,9 +149,85 @@ const ElicitationRequest = ({
onResolve(request.id, { action: "cancel" });
};

// Render URL mode elicitation
if (request.request.mode === "url") {
return (
<div
data-testid="elicitation-request"
className="flex gap-4 p-4 border rounded-lg"
>
<div className="flex-1 space-y-4">
<Alert>
<ExternalLink className="h-4 w-4" />
<AlertDescription>
<div className="space-y-2">
<p className="font-semibold">URL Elicitation Request</p>
<p className="text-sm">{request.request.message}</p>
{request.request.url && (
<div className="mt-2 space-y-1">
<p className="text-xs">
<span className="font-medium">Target Domain: </span>
<code className="bg-muted px-1 rounded">
{(() => {
try {
return new URL(request.request.url!).host;
} catch {
return request.request.url;
}
})()}
</code>
</p>
<p className="text-xs">
<span className="font-medium">URL: </span>
<span className="break-all text-muted-foreground">
{request.request.url}
</span>
</p>
</div>
)}
{request.request.elicitationId && (
<p className="text-xs">
<span className="font-medium">Elicitation ID: </span>
<span className="text-muted-foreground">
{request.request.elicitationId}
</span>
</p>
)}
</div>
</AlertDescription>
</Alert>

<div className="flex space-x-2">
<Button type="button" onClick={handleOpenUrl}>
Open URL
</Button>
<Button type="button" onClick={handleAccept}>
Accept
</Button>
<Button type="button" variant="outline" onClick={handleDecline}>
Decline
</Button>
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
</div>

{validationError && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<div className="text-sm text-red-600 dark:text-red-400">
<strong>Error:</strong> {validationError}
</div>
</div>
)}
</div>
</div>
);
}

// Render form mode elicitation (existing implementation)
const schemaTitle =
request.request.requestedSchema.title || "Information Request";
const schemaDescription = request.request.requestedSchema.description;
request.request.requestedSchema?.title || "Information Request";
const schemaDescription = request.request.requestedSchema?.description;

return (
<div
Expand All @@ -124,26 +241,30 @@ const ElicitationRequest = ({
{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)}
/>
</div>
{request.request.requestedSchema && (
<div className="mt-2">
<h5 className="text-xs font-medium mb-1">Request Schema:</h5>
<JsonView
data={JSON.stringify(request.request.requestedSchema, null, 2)}
/>
</div>
)}
</div>
</div>

<div className="flex-1 space-y-4">
<div className="space-y-2">
<h4 className="font-medium">Response Form</h4>
<DynamicJsonForm
schema={request.request.requestedSchema}
value={formData}
onChange={(newValue: JsonValue) => {
setFormData(newValue);
setValidationError(null);
}}
/>
{request.request.requestedSchema && (
<DynamicJsonForm
schema={request.request.requestedSchema}
value={formData}
onChange={(newValue: JsonValue) => {
setFormData(newValue);
setValidationError(null);
}}
/>
)}

{validationError && (
<div className="mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
Expand Down
Loading