From 8c135fb6a64ec6279ec2415a628b2c6c380cfe4b Mon Sep 17 00:00:00 2001 From: Pavel Bezglasnyy Date: Thu, 1 Jan 2026 20:10:59 +0100 Subject: [PATCH 1/5] feat(elicitation): Add URL elicitation support. SEP-1036 --- client/src/App.tsx | 3 + ...Request.tsx => ElicitationFormRequest.tsx} | 13 +- client/src/components/ElicitationTab.tsx | 91 ++- .../src/components/ElicitationUrlRequest.tsx | 167 +++++ ...st.tsx => ElicitationFormRequest.test.tsx} | 19 +- .../__tests__/ElicitationUrlRequest.test.tsx | 688 ++++++++++++++++++ .../hooks/__tests__/useConnection.test.tsx | 5 +- client/src/lib/hooks/useConnection.ts | 7 +- 8 files changed, 970 insertions(+), 23 deletions(-) rename client/src/components/{ElicitationRequest.tsx => ElicitationFormRequest.tsx} (94%) create mode 100644 client/src/components/ElicitationUrlRequest.tsx rename client/src/components/__tests__/{ElicitationRequest.test.tsx => ElicitationFormRequest.test.tsx} (91%) create mode 100644 client/src/components/__tests__/ElicitationUrlRequest.test.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index a9f99686d..14db1ad0e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -341,7 +341,10 @@ const App = () => { request: { id: nextRequestId.current, message: request.params.message, + mode: request.params.mode, requestedSchema: request.params.requestedSchema, + url: request.params.url, + elicitationId: request.params.elicitationId, }, originatingTab: currentTab, resolve, diff --git a/client/src/components/ElicitationRequest.tsx b/client/src/components/ElicitationFormRequest.tsx similarity index 94% rename from client/src/components/ElicitationRequest.tsx rename to client/src/components/ElicitationFormRequest.tsx index 4488a9620..aba3186e2 100644 --- a/client/src/components/ElicitationRequest.tsx +++ b/client/src/components/ElicitationFormRequest.tsx @@ -6,19 +6,22 @@ import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils"; import { generateDefaultValue } from "@/utils/schemaUtils"; import { PendingElicitationRequest, + FormElicitationRequestData, ElicitationResponse, } from "./ElicitationTab"; import Ajv from "ajv"; -export type ElicitationRequestProps = { - request: PendingElicitationRequest; +export type ElicitationFormRequestProps = { + request: PendingElicitationRequest & { + request: FormElicitationRequestData; + }; onResolve: (id: number, response: ElicitationResponse) => void; }; -const ElicitationRequest = ({ +const ElicitationFormRequest = ({ request, onResolve, -}: ElicitationRequestProps) => { +}: ElicitationFormRequestProps) => { const [formData, setFormData] = useState({}); const [validationError, setValidationError] = useState(null); @@ -170,4 +173,4 @@ const ElicitationRequest = ({ ); }; -export default ElicitationRequest; +export default ElicitationFormRequest; diff --git a/client/src/components/ElicitationTab.tsx b/client/src/components/ElicitationTab.tsx index cf3be2b5c..7e762e392 100644 --- a/client/src/components/ElicitationTab.tsx +++ b/client/src/components/ElicitationTab.tsx @@ -1,13 +1,28 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { TabsContent } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; import { JsonSchemaType } from "@/utils/jsonUtils"; -import ElicitationRequest from "./ElicitationRequest"; +import ElicitationFormRequest from "@/components/ElicitationFormRequest.tsx"; +import ElicitationUrlRequest from "@/components/ElicitationUrlRequest.tsx"; -export interface ElicitationRequestData { +export type FormElicitationRequestData = { + mode?: "form"; id: number; message: string; requestedSchema: JsonSchemaType; -} +}; + +export type UrlElicitationRequestData = { + mode: "url"; + id: number; + message: string; + url: string; + elicitationId: string; +}; + +export type ElicitationRequestData = + | FormElicitationRequestData + | UrlElicitationRequestData; export interface ElicitationResponse { action: "accept" | "decline" | "cancel"; @@ -25,6 +40,23 @@ export type Props = { onResolve: (id: number, response: ElicitationResponse) => void; }; +const isFormRequest = ( + req: PendingElicitationRequest, +): req is PendingElicitationRequest & { + request: FormElicitationRequestData; +} => { + const mode = req.request.mode; + return mode === undefined || mode === null || mode === "form"; +}; + +const isUrlElicitationRequest = ( + req: PendingElicitationRequest, +): req is PendingElicitationRequest & { + request: UrlElicitationRequestData; +} => { + return req.request.mode === "url"; +}; + const ElicitationTab = ({ pendingRequests, onResolve }: Props) => { return ( @@ -37,13 +69,52 @@ const ElicitationTab = ({ pendingRequests, onResolve }: Props) => {

Recent Requests

- {pendingRequests.map((request) => ( - - ))} + {pendingRequests.map((request) => { + if (isFormRequest(request)) { + return ( + + ); + } else if (isUrlElicitationRequest(request)) { + return ( + + ); + } + return ( +
+

+ Unsupported elicitation mode. You can decline or cancel this + request. +

+
+ + +
+
+ ); + })} {pendingRequests.length === 0 && (

No pending requests

)} diff --git a/client/src/components/ElicitationUrlRequest.tsx b/client/src/components/ElicitationUrlRequest.tsx new file mode 100644 index 000000000..65a47f80d --- /dev/null +++ b/client/src/components/ElicitationUrlRequest.tsx @@ -0,0 +1,167 @@ +import { + ElicitationResponse, + PendingElicitationRequest, + UrlElicitationRequestData, +} from "@/components/ElicitationTab.tsx"; +import JsonView from "@/components/JsonView.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { CheckCheck, Copy } from "lucide-react"; +import useCopy from "@/lib/hooks/useCopy.ts"; +import { toast } from "@/lib/hooks/useToast.ts"; + +export type ElicitationUrlRequestProps = { + request: PendingElicitationRequest & { + request: UrlElicitationRequestData; + }; + onResolve: (id: number, response: ElicitationResponse) => void; +}; + +const ElicitationUrlRequest = ({ + request, + onResolve, +}: ElicitationUrlRequestProps) => { + const { copied, setCopied } = useCopy(); + + const parsedUrl = (() => { + try { + return new URL(request.request.url); + } catch { + return null; + } + })(); + + const handleAcceptAndOpen = () => { + if (!parsedUrl) { + return; + } + + window.open(parsedUrl.href, "_blank", "noopener,noreferrer"); + + onResolve(request.id, { + action: "accept", + }); + }; + + const handleAccept = () => { + onResolve(request.id, { + action: "accept", + }); + }; + + const handleDecline = () => { + onResolve(request.id, { action: "decline" }); + }; + + const handleCancel = () => { + onResolve(request.id, { action: "cancel" }); + }; + + const warnings = (() => { + if (!parsedUrl) { + return []; + } + + const warnings: string[] = []; + + if (parsedUrl.protocol !== "https:") { + warnings.push("Not https protocol"); + } + + if (parsedUrl.hostname.includes("xn--")) { + warnings.push("This URL contains internationalized characters"); + } + if (/[^\u0020-\u007E]/.test(parsedUrl.hostname)) { + warnings.push("This URL contains non-Latin characters"); + } + return warnings; + })(); + + const domain = (() => { + if (parsedUrl) { + return parsedUrl.hostname; + } + console.error("Invalid URL in elicitation request."); + return "Invalid URL"; + })(); + + return ( +
+
+
+
+
Request Schema:
+ +
+
+
+ +
+
+ {warnings.length > 0 && + warnings.map((msg, index) => ( +
+ {msg} +
+ ))} +

{request.request.message}

+

Domain: {domain}

+

+ Full URL: {request.request.url} +

+
+
+ + + + + +
+
+
+ ); +}; + +export default ElicitationUrlRequest; diff --git a/client/src/components/__tests__/ElicitationRequest.test.tsx b/client/src/components/__tests__/ElicitationFormRequest.test.tsx similarity index 91% rename from client/src/components/__tests__/ElicitationRequest.test.tsx rename to client/src/components/__tests__/ElicitationFormRequest.test.tsx index f2af25936..16e305a34 100644 --- a/client/src/components/__tests__/ElicitationRequest.test.tsx +++ b/client/src/components/__tests__/ElicitationFormRequest.test.tsx @@ -1,8 +1,11 @@ import { render, screen, fireEvent, act } from "@testing-library/react"; import "@testing-library/jest-dom"; import { describe, it, jest, beforeEach, afterEach } from "@jest/globals"; -import ElicitationRequest from "../ElicitationRequest"; -import { PendingElicitationRequest } from "../ElicitationTab"; +import ElicitationFormRequest from "../ElicitationFormRequest"; +import { + FormElicitationRequestData, + PendingElicitationRequest, +} from "../ElicitationTab"; jest.mock("../DynamicJsonForm", () => { return function MockDynamicJsonForm({ @@ -38,6 +41,10 @@ jest.mock("../DynamicJsonForm", () => { describe("ElicitationRequest", () => { const mockOnResolve = jest.fn(); + type FormPendingElicitationRequest = PendingElicitationRequest & { + request: FormElicitationRequestData; + }; + beforeEach(() => { jest.clearAllMocks(); }); @@ -47,8 +54,8 @@ describe("ElicitationRequest", () => { }); const createMockRequest = ( - overrides: Partial = {}, - ): PendingElicitationRequest => ({ + overrides: Partial = {}, + ): FormPendingElicitationRequest => ({ id: 1, request: { id: 1, @@ -66,10 +73,10 @@ describe("ElicitationRequest", () => { }); const renderElicitationRequest = ( - request: PendingElicitationRequest = createMockRequest(), + request: FormPendingElicitationRequest = createMockRequest(), ) => { return render( - , + , ); }; diff --git a/client/src/components/__tests__/ElicitationUrlRequest.test.tsx b/client/src/components/__tests__/ElicitationUrlRequest.test.tsx new file mode 100644 index 000000000..212077ee5 --- /dev/null +++ b/client/src/components/__tests__/ElicitationUrlRequest.test.tsx @@ -0,0 +1,688 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { describe, it, jest, beforeEach, afterEach } from "@jest/globals"; +import ElicitationUrlRequest from "../ElicitationUrlRequest"; +import { + PendingElicitationRequest, + UrlElicitationRequestData, +} from "../ElicitationTab"; + +// Mock useCopy hook +const mockSetCopied = jest.fn(); +let mockCopied = false; +jest.mock("@/lib/hooks/useCopy.ts", () => ({ + __esModule: true, + default: jest.fn(() => ({ + copied: mockCopied, + setCopied: mockSetCopied, + })), +})); + +// Mock toast +jest.mock("@/lib/hooks/useToast.ts", () => ({ + toast: jest.fn(), +})); + +// Mock lucide-react icons +jest.mock("lucide-react", () => ({ + CheckCheck: ({ className }: { className?: string }) => ( +
+ CheckCheck +
+ ), + Copy: ({ className }: { className?: string }) => ( +
+ Copy +
+ ), +})); + +// Mock JsonView component +jest.mock("../JsonView", () => { + return function MockJsonView({ data }: { data: string }) { + return
{data}
; + }; +}); + +// Get the mocked toast function +const { toast } = jest.requireMock("@/lib/hooks/useToast.ts"); + +describe("ElicitationUrlRequest", () => { + const mockOnResolve = jest.fn(); + const mockWindowOpen = jest.fn(); + const mockConsoleError = jest.fn(); + const mockWriteText = jest.fn(); + const originalWindowOpen = window.open; + const originalConsoleError = console.error; + const originalClipboard = navigator.clipboard; + + type UrlPendingElicitationRequest = PendingElicitationRequest & { + request: UrlElicitationRequestData; + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockCopied = false; + window.open = mockWindowOpen; + console.error = mockConsoleError; + Object.defineProperty(navigator, "clipboard", { + value: { + writeText: mockWriteText, + }, + writable: true, + configurable: true, + }); + mockWriteText.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + window.open = originalWindowOpen; + console.error = originalConsoleError; + Object.defineProperty(navigator, "clipboard", { + value: originalClipboard, + writable: true, + configurable: true, + }); + }); + + const createMockRequest = ( + overrides: Partial = {}, + ): UrlPendingElicitationRequest => ({ + id: 1, + request: { + mode: "url", + id: 1, + message: "Please authorize access to your repositories.", + url: "https://github.com/login/oauth/authorize?client_id=abc123", + elicitationId: "550e8400-e29b-41d4-a716-446655440000", + }, + ...overrides, + }); + + const renderElicitationUrlRequest = ( + request: UrlPendingElicitationRequest = createMockRequest(), + ) => { + return render( + , + ); + }; + + describe("Rendering", () => { + it("should render the component", () => { + renderElicitationUrlRequest(); + expect(screen.getByTestId("elicitation-request")).toBeInTheDocument(); + }); + + it("should display request message", () => { + const message = "Please provide your API key to continue."; + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message, + url: "https://example.com/api-key", + elicitationId: "test-id", + }, + }), + ); + expect(screen.getByText(message)).toBeInTheDocument(); + }); + + it("should display domain extracted from URL", () => { + renderElicitationUrlRequest(); + expect(screen.getByText("Domain: github.com")).toBeInTheDocument(); + }); + + it("should display full URL", () => { + const url = "https://example.com/auth?code=xyz"; + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test message", + url, + elicitationId: "test-id", + }, + }), + ); + expect(screen.getByText(/Full URL:/)).toBeInTheDocument(); + expect( + screen.getByText((content, element) => { + return element?.textContent === `Full URL: ${url}`; + }), + ).toBeInTheDocument(); + }); + + it("should render all action buttons", () => { + renderElicitationUrlRequest(); + expect( + screen.getByRole("button", { name: /^accept and open$/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(); + expect( + screen.getByRole("button", { name: /copy url/i }), + ).toBeInTheDocument(); + }); + + it("should render JsonView with request schema", () => { + renderElicitationUrlRequest(); + expect(screen.getByTestId("json-view")).toBeInTheDocument(); + }); + + it("should display request data in JSON format", () => { + const request = createMockRequest(); + renderElicitationUrlRequest(request); + const jsonView = screen.getByTestId("json-view"); + const jsonData = JSON.parse(jsonView.textContent || "{}"); + expect(jsonData.message).toBe(request.request.message); + expect(jsonData.url).toBe(request.request.url); + expect(jsonData.elicitationId).toBe(request.request.elicitationId); + }); + }); + + describe("User Interactions", () => { + it("should call window.open and onResolve with accept action when 'Accept and open' button is clicked", () => { + const request = createMockRequest(); + renderElicitationUrlRequest(request); + + fireEvent.click(screen.getByRole("button", { name: /accept and open/i })); + + expect(mockWindowOpen).toHaveBeenCalledWith( + request.request.url, + "_blank", + "noopener,noreferrer", + ); + expect(mockOnResolve).toHaveBeenCalledWith(1, { + action: "accept", + }); + }); + + it("should call onResolve with decline action when Decline button is clicked", () => { + renderElicitationUrlRequest(); + + fireEvent.click(screen.getByRole("button", { name: /decline/i })); + + expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "decline" }); + expect(mockWindowOpen).not.toHaveBeenCalled(); + }); + + it("should call onResolve with cancel action when Cancel button is clicked", () => { + renderElicitationUrlRequest(); + + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + + expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "cancel" }); + expect(mockWindowOpen).not.toHaveBeenCalled(); + }); + }); + + describe("URL Validation and Warnings", () => { + it("should not show protocol warning for HTTPS URLs", () => { + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url: "https://example.com/secure", + elicitationId: "test-id", + }, + }), + ); + + expect(screen.queryByText("Not https protocol")).not.toBeInTheDocument(); + }); + + it("should show protocol warning for HTTP URLs", () => { + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url: "http://example.com/insecure", + elicitationId: "test-id", + }, + }), + ); + + expect(screen.getByText("Not https protocol")).toBeInTheDocument(); + }); + + it("should show warning for Punycode (internationalized) URLs", () => { + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url: "https://xn--nxasmq6b.com/path", + elicitationId: "test-id", + }, + }), + ); + + expect( + screen.getByText("This URL contains internationalized characters"), + ).toBeInTheDocument(); + }); + + it("should show warning for URLs with non-Latin characters", () => { + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url: "https://xn--r8jz45g.com/path", // Punycode for 見.com + elicitationId: "test-id", + }, + }), + ); + + expect( + screen.getByText("This URL contains internationalized characters"), + ).toBeInTheDocument(); + }); + + it("should show multiple warnings when applicable", () => { + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url: "http://xn--nxasmq6b.com/path", + elicitationId: "test-id", + }, + }), + ); + + expect(screen.getByText("Not https protocol")).toBeInTheDocument(); + expect( + screen.getByText("This URL contains internationalized characters"), + ).toBeInTheDocument(); + }); + }); + + describe("Invalid URL Handling", () => { + it("should display 'Invalid URL' for malformed URLs without crashing", () => { + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url: "not-a-valid-url", + elicitationId: "test-id", + }, + }), + ); + + expect(screen.getByText("Domain: Invalid URL")).toBeInTheDocument(); + expect(mockConsoleError).toHaveBeenCalled(); + }); + + it("should not show warnings for invalid URLs", () => { + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url: "invalid-url", + elicitationId: "test-id", + }, + }), + ); + + expect(screen.queryByText("Not https protocol")).not.toBeInTheDocument(); + expect( + screen.queryByText("This URL contains internationalized characters"), + ).not.toBeInTheDocument(); + }); + + it("should handle empty URL string", () => { + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url: "", + elicitationId: "test-id", + }, + }), + ); + + expect(screen.getByText("Domain: Invalid URL")).toBeInTheDocument(); + }); + + it("should still allow interaction with buttons when URL is invalid", () => { + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url: "invalid", + elicitationId: "test-id", + }, + }), + ); + + fireEvent.click(screen.getByRole("button", { name: /decline/i })); + expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "decline" }); + }); + }); + + describe("Different URL Protocols", () => { + it("should handle localhost URLs", () => { + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url: "http://localhost:3000/auth", + elicitationId: "test-id", + }, + }), + ); + + expect(screen.getByText("Domain: localhost")).toBeInTheDocument(); + expect(screen.getByText("Not https protocol")).toBeInTheDocument(); + }); + + it("should handle URLs with ports", () => { + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url: "https://example.com:8080/path", + elicitationId: "test-id", + }, + }), + ); + + expect(screen.getByText("Domain: example.com")).toBeInTheDocument(); + }); + + it("should handle URLs with query parameters", () => { + const url = + "https://example.com/auth?client_id=123&redirect_uri=http://localhost"; + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url, + elicitationId: "test-id", + }, + }), + ); + + expect( + screen.getByText((content, element) => { + return element?.textContent === `Full URL: ${url}`; + }), + ).toBeInTheDocument(); + }); + + it("should handle URLs with hash fragments", () => { + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url: "https://example.com/page#section", + elicitationId: "test-id", + }, + }), + ); + + expect(screen.getByText("Domain: example.com")).toBeInTheDocument(); + }); + }); + + describe("Request ID Handling", () => { + it("should use correct request ID when calling onResolve", () => { + const customId = 42; + renderElicitationUrlRequest( + createMockRequest({ + id: customId, + }), + ); + + fireEvent.click( + screen.getByRole("button", { name: /^accept and open$/i }), + ); + + expect(mockOnResolve).toHaveBeenCalledWith(customId, { + action: "accept", + }); + }); + + it("should pass different IDs for different actions", () => { + const customId = 99; + renderElicitationUrlRequest( + createMockRequest({ + id: customId, + }), + ); + + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + expect(mockOnResolve).toHaveBeenCalledWith(customId, { + action: "cancel", + }); + }); + }); + + describe("Copy URL Functionality", () => { + it("should copy URL to clipboard when Copy URL button is clicked", async () => { + const url = "https://example.com/auth"; + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url, + elicitationId: "test-id", + }, + }), + ); + + const copyButton = screen.getByRole("button", { name: /copy url/i }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith(url); + }); + }); + + it("should call setCopied when copy succeeds", async () => { + renderElicitationUrlRequest(); + + const copyButton = screen.getByRole("button", { name: /copy url/i }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(mockSetCopied).toHaveBeenCalledWith(true); + }); + }); + + it("should show Copy icon when not copied", () => { + mockCopied = false; + renderElicitationUrlRequest(); + + expect(screen.getByTestId("copy-icon")).toBeInTheDocument(); + expect(screen.queryByTestId("check-check-icon")).not.toBeInTheDocument(); + }); + + it("should show CheckCheck icon when copied", () => { + mockCopied = true; + renderElicitationUrlRequest(); + + expect(screen.getByTestId("check-check-icon")).toBeInTheDocument(); + expect(screen.queryByTestId("copy-icon")).not.toBeInTheDocument(); + }); + + it("should show toast error when clipboard write fails", async () => { + const error = new Error("Clipboard access denied"); + mockWriteText.mockRejectedValue(error); + + renderElicitationUrlRequest(); + + const copyButton = screen.getByRole("button", { name: /copy url/i }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(toast).toHaveBeenCalledWith({ + title: "Error", + description: expect.stringContaining("Clipboard access denied"), + }); + }); + }); + + it("should handle non-Error clipboard failures", async () => { + mockWriteText.mockRejectedValue("Unknown error"); + + renderElicitationUrlRequest(); + + const copyButton = screen.getByRole("button", { name: /copy url/i }); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(toast).toHaveBeenCalledWith({ + title: "Error", + description: expect.stringContaining("Unknown error"), + }); + }); + }); + }); + + describe("Button Disabled States", () => { + it("should disable 'Accept and open' button when URL is invalid", () => { + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url: "not-a-valid-url", + elicitationId: "test-id", + }, + }), + ); + + const acceptAndOpenButton = screen.getByRole("button", { + name: /^accept and open$/i, + }); + expect(acceptAndOpenButton).toBeDisabled(); + }); + + it("should enable 'Accept and open' button when URL is valid", () => { + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url: "https://example.com/valid", + elicitationId: "test-id", + }, + }), + ); + + const acceptAndOpenButton = screen.getByRole("button", { + name: /^accept and open$/i, + }); + expect(acceptAndOpenButton).not.toBeDisabled(); + }); + + it("should not call window.open when 'Accept and open' is clicked with invalid URL", () => { + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url: "invalid", + elicitationId: "test-id", + }, + }), + ); + + const acceptAndOpenButton = screen.getByRole("button", { + name: /^accept and open$/i, + }); + + // Button is disabled, but try to click anyway + fireEvent.click(acceptAndOpenButton); + + expect(mockWindowOpen).not.toHaveBeenCalled(); + }); + + it("should keep other buttons enabled when URL is invalid", () => { + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url: "invalid-url", + elicitationId: "test-id", + }, + }), + ); + + expect( + screen.getByRole("button", { name: /^accept$/i }), + ).not.toBeDisabled(); + expect( + screen.getByRole("button", { name: /decline/i }), + ).not.toBeDisabled(); + expect( + screen.getByRole("button", { name: /cancel/i }), + ).not.toBeDisabled(); + expect( + screen.getByRole("button", { name: /copy url/i }), + ).not.toBeDisabled(); + }); + + it("should disable 'Accept and open' for empty URL", () => { + renderElicitationUrlRequest( + createMockRequest({ + request: { + mode: "url", + id: 1, + message: "Test", + url: "", + elicitationId: "test-id", + }, + }), + ); + + const acceptAndOpenButton = screen.getByRole("button", { + name: /^accept and open$/i, + }); + expect(acceptAndOpenButton).toBeDisabled(); + }); + }); +}); diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index ccd929650..5e2161b70 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -264,7 +264,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 eb782041b..5a709f554 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -27,6 +27,7 @@ import { ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, + ElicitationCompleteNotificationSchema, Progress, LoggingLevel, ElicitRequestSchema, @@ -414,7 +415,10 @@ export function useConnection({ const clientCapabilities = { capabilities: { sampling: {}, - elicitation: {}, + elicitation: { + form: {}, + url: {}, + }, roots: { listChanged: true, }, @@ -695,6 +699,7 @@ export function useConnection({ ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, + ElicitationCompleteNotificationSchema, ].forEach((notificationSchema) => { client.setNotificationHandler(notificationSchema, onNotification); }); From 108631c2659c17161a03e84ae5f2c8916b79bbae Mon Sep 17 00:00:00 2001 From: Pavel Bezglasny Date: Thu, 1 Jan 2026 23:39:04 +0100 Subject: [PATCH 2/5] Update client/src/components/ElicitationUrlRequest.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- client/src/components/ElicitationUrlRequest.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/src/components/ElicitationUrlRequest.tsx b/client/src/components/ElicitationUrlRequest.tsx index 65a47f80d..ae05ddac5 100644 --- a/client/src/components/ElicitationUrlRequest.tsx +++ b/client/src/components/ElicitationUrlRequest.tsx @@ -70,9 +70,6 @@ const ElicitationUrlRequest = ({ if (parsedUrl.hostname.includes("xn--")) { warnings.push("This URL contains internationalized characters"); } - if (/[^\u0020-\u007E]/.test(parsedUrl.hostname)) { - warnings.push("This URL contains non-Latin characters"); - } return warnings; })(); From 1cfd1eb3fd7793d76101e11bfdc079ba940b2a58 Mon Sep 17 00:00:00 2001 From: Pavel Bezglasny Date: Thu, 1 Jan 2026 23:39:41 +0100 Subject: [PATCH 3/5] Update client/src/components/ElicitationUrlRequest.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- client/src/components/ElicitationUrlRequest.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/ElicitationUrlRequest.tsx b/client/src/components/ElicitationUrlRequest.tsx index ae05ddac5..48706b2eb 100644 --- a/client/src/components/ElicitationUrlRequest.tsx +++ b/client/src/components/ElicitationUrlRequest.tsx @@ -64,11 +64,11 @@ const ElicitationUrlRequest = ({ const warnings: string[] = []; if (parsedUrl.protocol !== "https:") { - warnings.push("Not https protocol"); + warnings.push("Not HTTPS protocol"); } if (parsedUrl.hostname.includes("xn--")) { - warnings.push("This URL contains internationalized characters"); + warnings.push("This URL contains internationalized (non-ASCII) characters"); } return warnings; })(); From f88e21a5e2686ae8768a54a383cacfeb98a99e00 Mon Sep 17 00:00:00 2001 From: Pavel Bezglasny Date: Thu, 1 Jan 2026 23:43:14 +0100 Subject: [PATCH 4/5] Update client/src/components/ElicitationUrlRequest.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- client/src/components/ElicitationUrlRequest.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/components/ElicitationUrlRequest.tsx b/client/src/components/ElicitationUrlRequest.tsx index 48706b2eb..3fb30451d 100644 --- a/client/src/components/ElicitationUrlRequest.tsx +++ b/client/src/components/ElicitationUrlRequest.tsx @@ -136,6 +136,7 @@ const ElicitationUrlRequest = ({ Cancel