Skip to content
Merged
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
24 changes: 18 additions & 6 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
149 changes: 140 additions & 9 deletions client/src/components/ElicitationRequest.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
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";
import { generateDefaultValue } from "@/utils/schemaUtils";
import {
PendingElicitationRequest,
ElicitationResponse,
ElicitationFormRequestData,
} from "./ElicitationTab";
import Ajv from "ajv";

Expand All @@ -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@]+$/;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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>
Expand All @@ -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);
Expand Down
16 changes: 15 additions & 1 deletion client/src/components/ElicitationTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down
Loading
Loading