diff --git a/client/src/App.tsx b/client/src/App.tsx index 184f04d93..7fad8e66b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -438,15 +438,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) => { diff --git a/client/src/components/ElicitationRequest.tsx b/client/src/components/ElicitationRequest.tsx index 4488a9620..5e474c659 100644 --- a/client/src/components/ElicitationRequest.tsx +++ b/client/src/components/ElicitationRequest.tsx @@ -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,94 @@ const ElicitationRequest = ({ }: ElicitationRequestProps) => { const [formData, setFormData] = useState({}); const [validationError, setValidationError] = useState(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) { + const handleConfirmOpen = () => { + window.open(requestData.url, "_blank", "noopener,noreferrer"); + setShowUrlConfirm(false); + }; + + return ( +
+
+
+

URL Request

+

{requestData.message}

+
+
+
+ + + + +
+ + + + + Open External URL + + The server is requesting you visit the following URL: + + +

+ {requestData.url} +

+ + + + +
+
+
+ ); + } + + // 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 +166,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 +200,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 (

{schemaTitle}

-

{request.request.message}

+

{formRequest.message}

{schemaDescription && (

{schemaDescription}

)}
Request Schema:
@@ -137,7 +228,7 @@ const ElicitationRequest = ({

Response Form

{ setFormData(newValue); diff --git a/client/src/components/ElicitationTab.tsx b/client/src/components/ElicitationTab.tsx index cf3be2b5c..155f8448b 100644 --- a/client/src/components/ElicitationTab.tsx +++ b/client/src/components/ElicitationTab.tsx @@ -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; diff --git a/client/src/components/__tests__/ElicitationRequest.test.tsx b/client/src/components/__tests__/ElicitationRequest.test.tsx index f2af25936..04d29d50a 100644 --- a/client/src/components/__tests__/ElicitationRequest.test.tsx +++ b/client/src/components/__tests__/ElicitationRequest.test.tsx @@ -199,4 +199,139 @@ 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" }); + }); + }); }); diff --git a/client/src/components/__tests__/ElicitationTab.test.tsx b/client/src/components/__tests__/ElicitationTab.test.tsx index ca5194619..979385ae4 100644 --- a/client/src/components/__tests__/ElicitationTab.test.tsx +++ b/client/src/components/__tests__/ElicitationTab.test.tsx @@ -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(); + }); }); diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 4907a085b..4989cd367 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -518,7 +518,10 @@ describe("useConnection", () => { }), expect.objectContaining({ capabilities: expect.objectContaining({ - elicitation: {}, + elicitation: { + form: {}, + url: {}, + }, }), }), ); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index e14d1037f..cc115a659 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -29,6 +29,7 @@ import { ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, + ElicitationCompleteNotificationSchema, Progress, LoggingLevel, ElicitRequestSchema, @@ -258,9 +259,19 @@ export function useConnection({ pushHistory(requestWithMetadata, response); } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - pushHistory(requestWithMetadata, { error: errorMessage }); + if (error instanceof McpError) { + pushHistory(requestWithMetadata, { + error: { + code: error.code, + message: error.rpcMessage ?? error.message, + ...(error.data !== undefined && { data: error.data }), + }, + }); + } else { + const errorMessage = + error instanceof Error ? error.message : String(error); + pushHistory(requestWithMetadata, { error: errorMessage }); + } throw error; } @@ -452,7 +463,10 @@ export function useConnection({ const clientCapabilities = { capabilities: { sampling: {}, - elicitation: {}, + elicitation: { + form: {}, + url: {}, + }, roots: { listChanged: true, }, @@ -754,6 +768,7 @@ export function useConnection({ ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, TaskStatusNotificationSchema, + ElicitationCompleteNotificationSchema, ].forEach((notificationSchema) => { client.setNotificationHandler(notificationSchema, onNotification); });