From e163aea0e078790ee46cbf4f7a4603bc627cea58 Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Thu, 19 Jun 2025 15:03:39 +0200 Subject: [PATCH 01/20] feat: add elicitations to useConnection --- client/src/lib/hooks/useConnection.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 9009e698f..01c9a0ac4 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -28,6 +28,7 @@ import { ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, Progress, + ElicitRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { useState } from "react"; @@ -62,6 +63,8 @@ interface UseConnectionOptions { // eslint-disable-next-line @typescript-eslint/no-explicit-any onPendingRequest?: (request: any, resolve: any, reject: any) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any + onElicitationRequest?: (request: any, resolve: any) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any getRoots?: () => any[]; } @@ -77,6 +80,7 @@ export function useConnection({ onNotification, onStdErrNotification, onPendingRequest, + onElicitationRequest, getRoots, }: UseConnectionOptions) { const [connectionStatus, setConnectionStatus] = @@ -295,6 +299,7 @@ export function useConnection({ { capabilities: { sampling: {}, + elicitation: {}, roots: { listChanged: true, }, @@ -521,6 +526,14 @@ export function useConnection({ }); } + if (onElicitationRequest) { + client.setRequestHandler(ElicitRequestSchema, async (request) => { + return new Promise((resolve) => { + onElicitationRequest(request, resolve); + }); + }); + } + setMcpClient(client); setConnectionStatus("connected"); } catch (e) { From e6166a734eccb49f96fba9f0e5cfa7add21b2618 Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Thu, 19 Jun 2025 15:17:54 +0200 Subject: [PATCH 02/20] feat: add new schema requirements to DynamicJsonForm --- client/src/components/DynamicJsonForm.tsx | 161 +++++++- .../__tests__/DynamicJsonForm.test.tsx | 384 ++++++++++++++++++ 2 files changed, 523 insertions(+), 22 deletions(-) diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 6a5993c32..77023b280 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -140,26 +140,136 @@ const DynamicJsonForm = ({ ); } + const isFieldRequired = (fieldPath: string[]): boolean => { + if (typeof schema.required === "boolean") { + return schema.required; + } + if (Array.isArray(schema.required) && fieldPath.length > 0) { + return schema.required.includes(fieldPath[fieldPath.length - 1]); + } + return false; + }; + + if (propSchema.type === "object" && propSchema.properties) { + const objectValue = (currentValue as Record) || {}; + + return ( +
+ {Object.entries(propSchema.properties).map( + ([fieldName, fieldSchema]) => { + const fieldPath = [...path, fieldName]; + const fieldValue = objectValue[fieldName]; + const fieldRequired = isFieldRequired([fieldName]); + + return ( +
+ + {fieldSchema.description && ( +

+ {fieldSchema.description} +

+ )} +
+ {renderFieldInput( + fieldSchema, + fieldValue, + fieldPath, + fieldRequired, + )} +
+
+ ); + }, + )} +
+ ); + } + + const fieldRequired = isFieldRequired(path); + return renderFieldInput(propSchema, currentValue, path, fieldRequired); + }; + + const renderFieldInput = ( + propSchema: JsonSchemaType, + currentValue: JsonValue, + path: string[], + fieldRequired: boolean, + ) => { switch (propSchema.type) { - case "string": + case "string": { + if (propSchema.enum) { + return ( + + ); + } + + let inputType = "text"; + switch (propSchema.format) { + case "email": + inputType = "email"; + break; + case "uri": + inputType = "url"; + break; + case "date": + inputType = "date"; + break; + case "date-time": + inputType = "datetime-local"; + break; + default: + inputType = "text"; + break; + } + return ( { const val = e.target.value; - // Allow clearing non-required fields by setting undefined - // This preserves the distinction between empty string and unset - if (!val && !propSchema.required) { + if (!val && !fieldRequired) { handleFieldChange(path, undefined); } else { handleFieldChange(path, val); } }} placeholder={propSchema.description} - required={propSchema.required} + required={fieldRequired} + minLength={propSchema.minLength} + maxLength={propSchema.maxLength} + pattern={propSchema.pattern} /> ); + } + case "number": return ( { const val = e.target.value; - // Allow clearing non-required number fields - // This preserves the distinction between 0 and unset - if (!val && !propSchema.required) { + if (!val && !fieldRequired) { handleFieldChange(path, undefined); } else { const num = Number(val); @@ -179,9 +287,12 @@ const DynamicJsonForm = ({ } }} placeholder={propSchema.description} - required={propSchema.required} + required={fieldRequired} + min={propSchema.minimum} + max={propSchema.maximum} /> ); + case "integer": return ( { const val = e.target.value; - // Allow clearing non-required integer fields - // This preserves the distinction between 0 and unset - if (!val && !propSchema.required) { + if (!val && !fieldRequired) { handleFieldChange(path, undefined); } else { const num = Number(val); - // Only update if it's a valid integer if (!isNaN(num) && Number.isInteger(num)) { handleFieldChange(path, num); } } }} placeholder={propSchema.description} - required={propSchema.required} + required={fieldRequired} + min={propSchema.minimum} + max={propSchema.maximum} /> ); + case "boolean": return ( - handleFieldChange(path, e.target.checked)} - className="w-4 h-4" - required={propSchema.required} - /> +
+ handleFieldChange(path, e.target.checked)} + className="w-4 h-4" + required={fieldRequired} + /> + + {propSchema.description || "Enable this option"} + +
); + default: return null; } diff --git a/client/src/components/__tests__/DynamicJsonForm.test.tsx b/client/src/components/__tests__/DynamicJsonForm.test.tsx index afd435d78..2a0da7998 100644 --- a/client/src/components/__tests__/DynamicJsonForm.test.tsx +++ b/client/src/components/__tests__/DynamicJsonForm.test.tsx @@ -1,4 +1,5 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; import { describe, it, expect, jest } from "@jest/globals"; import DynamicJsonForm from "../DynamicJsonForm"; import type { JsonSchemaType } from "@/utils/jsonUtils"; @@ -35,6 +36,130 @@ describe("DynamicJsonForm String Fields", () => { expect(input).toHaveProperty("type", "text"); }); }); + + describe("Format Support", () => { + it("should render email input for email format", () => { + const schema: JsonSchemaType = { + type: "string", + format: "email", + description: "Email address", + }; + render(); + + const input = screen.getByRole("textbox"); + expect(input).toHaveProperty("type", "email"); + }); + + it("should render url input for uri format", () => { + const schema: JsonSchemaType = { + type: "string", + format: "uri", + description: "Website URL", + }; + render(); + + const input = screen.getByRole("textbox"); + expect(input).toHaveProperty("type", "url"); + }); + + it("should render date input for date format", () => { + const schema: JsonSchemaType = { + type: "string", + format: "date", + description: "Birth date", + }; + render(); + + const input = screen.getByDisplayValue(""); + expect(input).toHaveProperty("type", "date"); + }); + + it("should render datetime-local input for date-time format", () => { + const schema: JsonSchemaType = { + type: "string", + format: "date-time", + description: "Event datetime", + }; + render(); + + const input = screen.getByDisplayValue(""); + expect(input).toHaveProperty("type", "datetime-local"); + }); + }); + + describe("Enum Support", () => { + it("should render select dropdown for enum fields", () => { + const schema: JsonSchemaType = { + type: "string", + enum: ["option1", "option2", "option3"], + description: "Select an option", + }; + render(); + + const select = screen.getByRole("combobox"); + expect(select.tagName).toBe("SELECT"); + + const options = screen.getAllByRole("option"); + expect(options).toHaveLength(4); + }); + + it("should use enumNames for option labels", () => { + const schema: JsonSchemaType = { + type: "string", + enum: ["val1", "val2"], + enumNames: ["Label 1", "Label 2"], + description: "Select with labels", + }; + render(); + + const options = screen.getAllByRole("option"); + expect(options[1]).toHaveProperty("textContent", "Label 1"); + expect(options[2]).toHaveProperty("textContent", "Label 2"); + }); + + it("should call onChange with selected enum value", () => { + const onChange = jest.fn(); + const schema: JsonSchemaType = { + type: "string", + enum: ["option1", "option2"], + description: "Select an option", + }; + render(); + + const select = screen.getByRole("combobox"); + fireEvent.change(select, { target: { value: "option1" } }); + + expect(onChange).toHaveBeenCalledWith("option1"); + }); + }); + + describe("Validation Attributes", () => { + it("should apply minLength and maxLength", () => { + const schema: JsonSchemaType = { + type: "string", + minLength: 3, + maxLength: 10, + description: "Username", + }; + render(); + + const input = screen.getByRole("textbox"); + expect(input).toHaveProperty("minLength", 3); + expect(input).toHaveProperty("maxLength", 10); + }); + + it("should apply pattern validation", () => { + const schema: JsonSchemaType = { + type: "string", + pattern: "^[A-Za-z]+$", + description: "Letters only", + }; + render(); + + const input = screen.getByRole("textbox"); + expect(input).toHaveProperty("pattern", "^[A-Za-z]+$"); + }); + }); }); describe("DynamicJsonForm Integer Fields", () => { @@ -81,6 +206,38 @@ describe("DynamicJsonForm Integer Fields", () => { }); }); + describe("Validation", () => { + it("should apply min and max constraints", () => { + const schema: JsonSchemaType = { + type: "integer", + minimum: 0, + maximum: 100, + description: "Age", + }; + render( + , + ); + + const input = screen.getByRole("spinbutton"); + expect(input).toHaveProperty("min", "0"); + expect(input).toHaveProperty("max", "100"); + }); + + it("should only accept integer values", () => { + const onChange = jest.fn(); + const schema: JsonSchemaType = { + type: "integer", + description: "Count", + }; + render(); + + const input = screen.getByRole("spinbutton"); + fireEvent.change(input, { target: { value: "3.14" } }); + + expect(onChange).not.toHaveBeenCalled(); + }); + }); + describe("Edge Cases", () => { it("should handle non-numeric input by not calling onChange", () => { const onChange = jest.fn(); @@ -94,6 +251,233 @@ describe("DynamicJsonForm Integer Fields", () => { }); }); +describe("DynamicJsonForm Number Fields", () => { + describe("Validation", () => { + it("should apply min and max constraints", () => { + const schema: JsonSchemaType = { + type: "number", + minimum: 0.5, + maximum: 99.9, + description: "Score", + }; + render( + , + ); + + const input = screen.getByRole("spinbutton"); + expect(input).toHaveProperty("min", "0.5"); + expect(input).toHaveProperty("max", "99.9"); + }); + + it("should accept decimal values", () => { + const onChange = jest.fn(); + const schema: JsonSchemaType = { + type: "number", + description: "Temperature", + }; + render(); + + const input = screen.getByRole("spinbutton"); + fireEvent.change(input, { target: { value: "98.6" } }); + + expect(onChange).toHaveBeenCalledWith(98.6); + }); + }); +}); + +describe("DynamicJsonForm Boolean Fields", () => { + describe("Basic Operations", () => { + it("should render checkbox for boolean type", () => { + const schema: JsonSchemaType = { + type: "boolean", + description: "Enable notifications", + }; + render( + , + ); + + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).toHaveProperty("type", "checkbox"); + }); + + it("should call onChange with boolean value", () => { + const onChange = jest.fn(); + const schema: JsonSchemaType = { + type: "boolean", + description: "Accept terms", + }; + render( + , + ); + + const checkbox = screen.getByRole("checkbox"); + fireEvent.click(checkbox); + + expect(onChange).toHaveBeenCalledWith(true); + }); + + it("should render boolean field with description", () => { + const schema: JsonSchemaType = { + type: "boolean", + description: "Enable dark mode", + }; + render( + , + ); + + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).toHaveProperty("checked", false); + }); + }); +}); + +describe("DynamicJsonForm Object Fields", () => { + describe("Property Rendering", () => { + it("should render input fields for object properties", () => { + const schema: JsonSchemaType = { + type: "object", + properties: { + name: { + type: "string", + title: "Full Name", + description: "Your full name", + }, + age: { + type: "integer", + title: "Age", + description: "Your age in years", + minimum: 18, + }, + email: { + type: "string", + format: "email", + title: "Email", + description: "Your email address", + }, + }, + }; + render( + , + ); + + const textInputs = screen.getAllByRole("textbox"); + const numberInput = screen.getByRole("spinbutton"); + + expect(textInputs).toHaveLength(2); + expect(textInputs[0]).toHaveProperty("type", "text"); + expect(textInputs[1]).toHaveProperty("type", "email"); + expect(numberInput).toHaveProperty("type", "number"); + expect(numberInput).toHaveProperty("min", "18"); + }); + + it("should handle object field changes correctly", () => { + const onChange = jest.fn(); + const schema: JsonSchemaType = { + type: "object", + properties: { + username: { + type: "string", + description: "Your username", + }, + }, + }; + render( + , + ); + + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "testuser" } }); + + expect(onChange).toHaveBeenCalledWith({ username: "testuser" }); + }); + + it("should handle nested object values correctly", () => { + const onChange = jest.fn(); + const schema: JsonSchemaType = { + type: "object", + properties: { + name: { + type: "string", + title: "Name", + }, + }, + }; + render( + , + ); + + const input = screen.getByDisplayValue("John"); + fireEvent.change(input, { target: { value: "Jane" } }); + + expect(onChange).toHaveBeenCalledWith({ name: "Jane" }); + }); + }); + + describe("Required Fields", () => { + it("should mark required fields with required attribute", () => { + const schema: JsonSchemaType = { + type: "object", + properties: { + name: { + type: "string", + title: "Name", + }, + email: { + type: "string", + title: "Email", + }, + }, + required: ["name"], + }; + render( + , + ); + + const inputs = screen.getAllByRole("textbox"); + const nameInput = inputs[0]; + const emailInput = inputs[1]; + + expect(nameInput).toHaveProperty("required", true); + expect(emailInput).toHaveProperty("required", false); + }); + + it("should mark required fields with required attribute", () => { + const schema: JsonSchemaType = { + type: "object", + properties: { + name: { + type: "string", + title: "Name", + }, + optional: { + type: "string", + title: "Optional", + }, + }, + required: ["name"], + }; + render( + , + ); + + const nameLabel = screen.getByText("Name"); + const optionalLabel = screen.getByText("Optional"); + + const nameInput = nameLabel.closest("div")?.querySelector("input"); + const optionalInput = optionalLabel + .closest("div") + ?.querySelector("input"); + + expect(nameInput).toHaveProperty("required", true); + expect(optionalInput).toHaveProperty("required", false); + }); + }); +}); + describe("DynamicJsonForm Complex Fields", () => { const renderForm = (props = {}) => { const defaultProps = { From a058229fb689c0b8b0abff3f64ed7a90b3671064 Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Thu, 19 Jun 2025 16:14:57 +0200 Subject: [PATCH 03/20] feat: add ElicitationModal --- client/package.json | 2 +- client/src/components/ElicitationModal.tsx | 181 ++++++ .../__tests__/ElicitationModal.test.tsx | 535 ++++++++++++++++++ 3 files changed, 717 insertions(+), 1 deletion(-) create mode 100644 client/src/components/ElicitationModal.tsx create mode 100644 client/src/components/__tests__/ElicitationModal.test.tsx diff --git a/client/package.json b/client/package.json index 37a0675b9..ffeafcc7e 100644 --- a/client/package.json +++ b/client/package.json @@ -25,7 +25,6 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", "@radix-ui/react-checkbox": "^1.1.4", - "ajv": "^6.12.6", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", @@ -35,6 +34,7 @@ "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", + "ajv": "^6.12.6", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.4", diff --git a/client/src/components/ElicitationModal.tsx b/client/src/components/ElicitationModal.tsx new file mode 100644 index 000000000..8a742a313 --- /dev/null +++ b/client/src/components/ElicitationModal.tsx @@ -0,0 +1,181 @@ +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import DynamicJsonForm from "./DynamicJsonForm"; +import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils"; +import { generateDefaultValue } from "@/utils/schemaUtils"; +import Ajv from "ajv"; + +export interface ElicitationRequest { + id: number; + message: string; + requestedSchema: JsonSchemaType; + resolve: (response: ElicitationResponse) => void; +} + +export interface ElicitationResponse { + action: "accept" | "reject" | "cancel"; + content?: Record; +} + +interface ElicitationModalProps { + request: ElicitationRequest | null; + onClose: () => void; +} + +const ElicitationModal = ({ request, onClose }: ElicitationModalProps) => { + const [formData, setFormData] = useState({}); + const [validationError, setValidationError] = useState(null); + + useEffect(() => { + if (request) { + const defaultValue = generateDefaultValue(request.requestedSchema); + setFormData(defaultValue); + setValidationError(null); + } + }, [request]); + + if (!request) return null; + + const validateEmailFormat = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const validateFormData = ( + data: JsonValue, + schema: JsonSchemaType, + ): boolean => { + if ( + schema.type === "object" && + schema.properties && + typeof data === "object" && + data !== null + ) { + const dataObj = data as Record; + + if (Array.isArray(schema.required)) { + for (const field of schema.required) { + const value = dataObj[field]; + if (value === undefined || value === null || value === "") { + setValidationError(`Required field missing: ${field}`); + return false; + } + } + } + + for (const [fieldName, fieldValue] of Object.entries(dataObj)) { + const fieldSchema = schema.properties[fieldName]; + if ( + fieldSchema && + fieldSchema.format === "email" && + typeof fieldValue === "string" + ) { + if (!validateEmailFormat(fieldValue)) { + setValidationError(`Invalid email format: ${fieldName}`); + return false; + } + } + } + } + + return true; + }; + + const handleAccept = () => { + try { + if (!validateFormData(formData, request.requestedSchema)) { + return; + } + + const ajv = new Ajv(); + const validate = ajv.compile(request.requestedSchema); + const isValid = validate(formData); + + if (!isValid) { + const errorMessage = ajv.errorsText(validate.errors); + setValidationError(errorMessage); + return; + } + + request.resolve({ + action: "accept", + content: formData as Record, + }); + onClose(); + } catch (error) { + setValidationError( + error instanceof Error ? error.message : "Validation failed", + ); + } + }; + + const handleReject = () => { + request.resolve({ action: "reject" }); + onClose(); + }; + + const handleCancel = () => { + request.resolve({ action: "cancel" }); + onClose(); + }; + + const schemaTitle = request.requestedSchema.title || "Information Request"; + const schemaDescription = request.requestedSchema.description; + + return ( + + + + {schemaTitle} + + {request.message} + {schemaDescription && ( + + {schemaDescription} + + )} + + + +
+ { + setFormData(newValue); + setValidationError(null); + }} + /> + + {validationError && ( +
+
+ Validation Error: {validationError} +
+
+ )} +
+ + + + + + +
+
+ ); +}; + +export default ElicitationModal; diff --git a/client/src/components/__tests__/ElicitationModal.test.tsx b/client/src/components/__tests__/ElicitationModal.test.tsx new file mode 100644 index 000000000..31a06815f --- /dev/null +++ b/client/src/components/__tests__/ElicitationModal.test.tsx @@ -0,0 +1,535 @@ +import { render, screen, fireEvent, act } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { describe, it, jest, beforeEach, afterEach } from "@jest/globals"; +import ElicitationModal, { ElicitationRequest } from "../ElicitationModal"; +import { JsonSchemaType } from "@/utils/jsonUtils"; +import * as schemaUtils from "@/utils/schemaUtils"; + +jest.mock("../DynamicJsonForm", () => { + return function MockDynamicJsonForm({ + value, + onChange, + }: { + value: unknown; + onChange: (value: unknown) => void; + }) { + return ( +
+ { + try { + const parsed = JSON.parse(e.target.value); + onChange(parsed); + } catch { + onChange(e.target.value); + } + }} + /> +
+ ); + }; +}); + +jest.mock("@/components/ui/dialog", () => ({ + Dialog: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode; + open: boolean; + onOpenChange: () => void; + }) => + open ? ( +
+ {children} +
+ ) : null, + DialogContent: ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) => ( +
+ {children} +
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + DialogDescription: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogFooter: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +jest.mock("@/components/ui/button", () => ({ + Button: ({ + children, + onClick, + variant, + ...props + }: { + children: React.ReactNode; + onClick?: () => void; + variant?: string; + [key: string]: unknown; + }) => ( + + ), +})); + +jest.mock("@/utils/schemaUtils", () => ({ + generateDefaultValue: jest.fn((schema: JsonSchemaType) => { + if (schema.type === "object" && schema.properties) { + const defaults: Record = {}; + Object.keys(schema.properties).forEach((key) => { + const prop = schema.properties![key]; + if (prop.type === "string") defaults[key] = ""; + if (prop.type === "number") defaults[key] = 0; + if (prop.type === "boolean") defaults[key] = false; + }); + return defaults; + } + return {}; + }), +})); + +describe("ElicitationModal", () => { + const mockResolve = jest.fn(); + const mockOnClose = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const createMockRequest = ( + overrides: Partial = {}, + ): ElicitationRequest => ({ + id: 1, + message: "Please provide your information", + requestedSchema: { + type: "object", + properties: { + name: { type: "string", description: "Your name" }, + email: { type: "string", format: "email", description: "Your email" }, + }, + required: ["name"], + }, + resolve: mockResolve, + ...overrides, + }); + + const renderElicitationModal = ( + request: ElicitationRequest | null = null, + ) => { + return render( + , + ); + }; + + describe("Rendering", () => { + it("should render null when no request is provided", () => { + render(); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); + }); + + it("should render dialog when request is provided", () => { + renderElicitationModal(); + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-content")).toBeInTheDocument(); + }); + + it("should display request message", () => { + const message = "Please provide your GitHub username"; + renderElicitationModal(createMockRequest({ message })); + expect(screen.getByText(message)).toBeInTheDocument(); + }); + + it("should display schema title when provided", () => { + const request = createMockRequest({ + requestedSchema: { + type: "object", + title: "User Information", + properties: { name: { type: "string" } }, + }, + }); + renderElicitationModal(request); + expect(screen.getByText("User Information")).toBeInTheDocument(); + }); + + it("should display default title when schema title is not provided", () => { + renderElicitationModal(); + expect(screen.getByText("Information Request")).toBeInTheDocument(); + }); + + it("should display schema description when provided", () => { + const description = "Please fill out your contact details"; + const request = createMockRequest({ + requestedSchema: { + type: "object", + description, + properties: { name: { type: "string" } }, + }, + }); + renderElicitationModal(request); + expect(screen.getByText(description)).toBeInTheDocument(); + }); + + it("should render all three action buttons", () => { + renderElicitationModal(); + expect(screen.getByTestId("button-cancel")).toBeInTheDocument(); + expect(screen.getByTestId("button-decline")).toBeInTheDocument(); + expect(screen.getByTestId("button-submit")).toBeInTheDocument(); + }); + + it("should render DynamicJsonForm component", () => { + renderElicitationModal(); + expect(screen.getByTestId("dynamic-json-form")).toBeInTheDocument(); + }); + }); + + describe("User Interactions", () => { + it("should call resolve with accept action when Submit button is clicked", async () => { + renderElicitationModal(); + + const input = screen.getByTestId("form-input"); + await act(async () => { + fireEvent.change(input, { + target: { + value: '{"name": "John Doe", "email": "john@example.com"}', + }, + }); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId("button-submit")); + }); + + expect(mockResolve).toHaveBeenCalledWith({ + action: "accept", + content: { name: "John Doe", email: "john@example.com" }, + }); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it("should call resolve with reject action when Decline button is clicked", async () => { + renderElicitationModal(); + + await act(async () => { + fireEvent.click(screen.getByTestId("button-decline")); + }); + + expect(mockResolve).toHaveBeenCalledWith({ action: "reject" }); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it("should call resolve with cancel action when Cancel button is clicked", async () => { + renderElicitationModal(); + + await act(async () => { + fireEvent.click(screen.getByTestId("button-cancel")); + }); + + expect(mockResolve).toHaveBeenCalledWith({ action: "cancel" }); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it("should call resolve with cancel action when dialog is closed", async () => { + renderElicitationModal(); + + await act(async () => { + fireEvent.click(screen.getByTestId("dialog")); + }); + + expect(mockResolve).toHaveBeenCalledWith({ action: "cancel" }); + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + describe("Form Validation", () => { + it("should show validation error for missing required fields", async () => { + renderElicitationModal(); + + await act(async () => { + fireEvent.click(screen.getByTestId("button-submit")); + }); + + expect( + screen.getByText(/Required field missing: name/), + ).toBeInTheDocument(); + expect(mockResolve).not.toHaveBeenCalledWith( + expect.objectContaining({ action: "accept" }), + ); + }); + + it("should show validation error for invalid email format", async () => { + renderElicitationModal(); + + const input = screen.getByTestId("form-input"); + await act(async () => { + fireEvent.change(input, { + target: { value: '{"name": "John", "email": "invalid-email"}' }, + }); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId("button-submit")); + }); + + expect( + screen.getByText(/Invalid email format: email/), + ).toBeInTheDocument(); + expect(mockResolve).not.toHaveBeenCalledWith( + expect.objectContaining({ action: "accept" }), + ); + }); + + it("should clear validation error when form data changes", async () => { + renderElicitationModal(); + + await act(async () => { + fireEvent.click(screen.getByTestId("button-submit")); + }); + + expect(screen.getByText(/Required field missing/)).toBeInTheDocument(); + + const input = screen.getByTestId("form-input"); + await act(async () => { + fireEvent.change(input, { target: { value: '{"name": "John"}' } }); + }); + + expect( + screen.queryByText(/Required field missing/), + ).not.toBeInTheDocument(); + }); + + it("should show AJV validation errors", async () => { + const mockAjv = { + compile: jest.fn(() => ({ + errors: [{ instancePath: "/name", message: "should be string" }], + })), + errorsText: jest.fn(() => "data/name should be string"), + }; + + jest.doMock("ajv", () => jest.fn(() => mockAjv)); + + renderElicitationModal(); + + const input = screen.getByTestId("form-input"); + await act(async () => { + fireEvent.change(input, { target: { value: '{"name": "John"}' } }); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId("button-submit")); + }); + }); + }); + + describe("Email Validation", () => { + it("should accept valid email formats", async () => { + const validEmails = [ + "test@example.com", + "user.name@domain.co.uk", + "user+tag@example.org", + ]; + + for (const email of validEmails) { + renderElicitationModal(); + + const input = screen.getByTestId("form-input"); + await act(async () => { + fireEvent.change(input, { + target: { value: `{"name": "John", "email": "${email}"}` }, + }); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId("button-submit")); + }); + + expect(mockResolve).toHaveBeenCalledWith({ + action: "accept", + content: { name: "John", email }, + }); + + jest.clearAllMocks(); + document.body.innerHTML = ""; + } + }); + + it("should reject invalid email formats", async () => { + const invalidEmails = [ + "invalid-email", + "@example.com", + "user@", + "user.example.com", + ]; + + for (const email of invalidEmails) { + renderElicitationModal(); + + const input = screen.getByTestId("form-input"); + await act(async () => { + fireEvent.change(input, { + target: { value: `{"name": "John", "email": "${email}"}` }, + }); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId("button-submit")); + }); + + expect( + screen.getByText(/Invalid email format: email/), + ).toBeInTheDocument(); + expect(mockResolve).not.toHaveBeenCalledWith( + expect.objectContaining({ action: "accept" }), + ); + + jest.clearAllMocks(); + document.body.innerHTML = ""; + } + }); + }); + + describe("Schema Types", () => { + it("should handle string schema", () => { + const request = createMockRequest({ + requestedSchema: { + type: "object", + properties: { + username: { type: "string", description: "Username" }, + }, + }, + }); + renderElicitationModal(request); + expect(screen.getByTestId("dynamic-json-form")).toBeInTheDocument(); + }); + + it("should handle number schema", () => { + const request = createMockRequest({ + requestedSchema: { + type: "object", + properties: { + age: { type: "number", minimum: 0, maximum: 120 }, + }, + }, + }); + renderElicitationModal(request); + expect(screen.getByTestId("dynamic-json-form")).toBeInTheDocument(); + }); + + it("should handle boolean schema", () => { + const request = createMockRequest({ + requestedSchema: { + type: "object", + properties: { + subscribe: { + type: "boolean", + description: "Subscribe to newsletter", + }, + }, + }, + }); + renderElicitationModal(request); + expect(screen.getByTestId("dynamic-json-form")).toBeInTheDocument(); + }); + + it("should handle enum schema", () => { + const request = createMockRequest({ + requestedSchema: { + type: "object", + properties: { + country: { + type: "string", + enum: ["US", "UK", "CA"], + enumNames: ["United States", "United Kingdom", "Canada"], + }, + }, + }, + }); + renderElicitationModal(request); + expect(screen.getByTestId("dynamic-json-form")).toBeInTheDocument(); + }); + }); + + describe("Error Handling", () => { + it("should handle validation errors gracefully", async () => { + renderElicitationModal(); + + await act(async () => { + fireEvent.click(screen.getByTestId("button-submit")); + }); + + expect(screen.getByText(/Validation Error/)).toBeInTheDocument(); + }); + + it("should handle JSON parsing errors in form validation", async () => { + renderElicitationModal(); + + const input = screen.getByTestId("form-input"); + await act(async () => { + fireEvent.change(input, { target: { value: "invalid json" } }); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId("button-submit")); + }); + + expect(mockResolve).not.toHaveBeenCalledWith( + expect.objectContaining({ action: "accept" }), + ); + }); + }); + + describe("Default Values", () => { + it("should generate default values when request changes", () => { + const { rerender } = renderElicitationModal(); + + const newRequest = createMockRequest({ + id: 2, + requestedSchema: { + type: "object", + properties: { + newField: { type: "string" }, + }, + }, + }); + + rerender(); + + expect(schemaUtils.generateDefaultValue).toHaveBeenCalledWith( + newRequest.requestedSchema, + ); + }); + }); +}); From fc939ebeb06c9b4e6181e8ebaff4e81535dda187 Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Thu, 19 Jun 2025 16:32:53 +0200 Subject: [PATCH 04/20] feat: add elicitation support to jsonUtils --- client/src/utils/jsonUtils.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/client/src/utils/jsonUtils.ts b/client/src/utils/jsonUtils.ts index 28cbf3030..9ba5ceef4 100644 --- a/client/src/utils/jsonUtils.ts +++ b/client/src/utils/jsonUtils.ts @@ -16,11 +16,20 @@ export type JsonSchemaType = { | "array" | "object" | "null"; + title?: string; description?: string; - required?: boolean; + required?: boolean | string[]; default?: JsonValue; properties?: Record; items?: JsonSchemaType; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + pattern?: string; + format?: string; + enum?: string[]; + enumNames?: string[]; }; export type JsonObject = { [key: string]: JsonValue }; From 6c809bdab643d00445db8a4d002950640bd0e5aa Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Thu, 19 Jun 2025 16:33:05 +0200 Subject: [PATCH 05/20] test: new elicitation format for jsonUtils --- client/src/utils/__tests__/jsonUtils.test.ts | 111 ++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/client/src/utils/__tests__/jsonUtils.test.ts b/client/src/utils/__tests__/jsonUtils.test.ts index 055e1dfd0..92a925a86 100644 --- a/client/src/utils/__tests__/jsonUtils.test.ts +++ b/client/src/utils/__tests__/jsonUtils.test.ts @@ -4,7 +4,7 @@ import { updateValueAtPath, getValueAtPath, } from "../jsonUtils"; -import type { JsonValue } from "../jsonUtils"; +import type { JsonValue, JsonSchemaType } from "../jsonUtils"; describe("getDataType", () => { test("should return 'string' for string values", () => { @@ -317,3 +317,112 @@ describe("getValueAtPath", () => { expect(getValueAtPath(obj, ["users", "1", "name"])).toBe("Jane"); }); }); + +describe("JsonSchemaType elicitation field support", () => { + const sampleSchema: JsonSchemaType = { + type: "object", + title: "User Info", + description: "User information form", + properties: { + name: { + type: "string", + title: "Full Name", + description: "Your full name", + minLength: 2, + maxLength: 50, + pattern: "^[A-Za-z\\s]+$", + }, + email: { + type: "string", + format: "email", + title: "Email Address", + }, + age: { + type: "integer", + minimum: 18, + maximum: 120, + default: 25, + }, + role: { + type: "string", + enum: ["admin", "user", "guest"], + enumNames: ["Administrator", "User", "Guest"], + }, + }, + required: ["name", "email"], + }; + + test("should parse JsonSchemaType with elicitation fields", () => { + const schemaString = JSON.stringify(sampleSchema); + const result = tryParseJson(schemaString); + + expect(result.success).toBe(true); + expect(result.data).toEqual(sampleSchema); + }); + + test("should update schema properties with new validation fields", () => { + const updated = updateValueAtPath( + sampleSchema, + ["properties", "name", "minLength"], + 5, + ); + + expect(getValueAtPath(updated, ["properties", "name", "minLength"])).toBe( + 5, + ); + }); + + test("should handle enum and enumNames fields", () => { + const schema = { + type: "string" as const, + enum: ["option1", "option2"], + enumNames: ["Option 1", "Option 2"], + }; + + expect(getValueAtPath(schema, ["enum", "0"])).toBe("option1"); + expect(getValueAtPath(schema, ["enumNames", "1"])).toBe("Option 2"); + }); + + test("should handle validation constraints", () => { + const numberSchema = { + type: "number" as const, + minimum: 0, + maximum: 100, + default: 50, + }; + + expect(getValueAtPath(numberSchema, ["minimum"])).toBe(0); + expect(getValueAtPath(numberSchema, ["maximum"])).toBe(100); + expect(getValueAtPath(numberSchema, ["default"])).toBe(50); + }); + + test("should handle string format and pattern fields", () => { + const stringSchema = { + type: "string" as const, + format: "email", + pattern: "^[a-z]+@[a-z]+\\.[a-z]+$", + minLength: 5, + maxLength: 100, + }; + + expect(getValueAtPath(stringSchema, ["format"])).toBe("email"); + expect(getValueAtPath(stringSchema, ["pattern"])).toBe( + "^[a-z]+@[a-z]+\\.[a-z]+$", + ); + expect(getValueAtPath(stringSchema, ["minLength"])).toBe(5); + }); + + test("should handle title and description fields", () => { + const schema = { + type: "boolean" as const, + title: "Accept Terms", + description: "Do you accept the terms and conditions?", + default: false, + }; + + expect(getValueAtPath(schema, ["title"])).toBe("Accept Terms"); + expect(getValueAtPath(schema, ["description"])).toBe( + "Do you accept the terms and conditions?", + ); + }); +}); From 124c78125365c4a41e5479b08ad307a5b472f45b Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Thu, 19 Jun 2025 17:22:44 +0200 Subject: [PATCH 06/20] feat: add elicitation handling to App --- client/src/App.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/client/src/App.tsx b/client/src/App.tsx index 26eb44c5e..8e5570ae5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -71,6 +71,9 @@ import { initializeInspectorConfig, saveInspectorConfig, } from "./utils/configUtils"; +import ElicitationModal, { + ElicitationRequest, +} from "./components/ElicitationModal"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; @@ -124,6 +127,8 @@ const App = () => { } > >([]); + const [pendingElicitationRequest, setPendingElicitationRequest] = + useState(null); const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); // Auth debugger state @@ -200,6 +205,14 @@ const App = () => { { id: nextRequestId.current++, request, resolve, reject }, ]); }, + onElicitationRequest: (request, resolve) => { + setPendingElicitationRequest({ + id: nextRequestId.current++, + message: request.params.message, + requestedSchema: request.params.requestedSchema, + resolve, + }); + }, getRoots: () => rootsRef.current, }); @@ -586,6 +599,10 @@ const App = () => { setStdErrNotifications([]); }; + const handleCloseElicitationModal = () => { + setPendingElicitationRequest(null); + }; + // Helper component for rendering the AuthDebugger const AuthDebuggerWrapper = () => ( @@ -938,6 +955,11 @@ const App = () => { + + ); }; From dfbf10c575c6ecbf4c8a5226ef06b84acf2dba1b Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Thu, 19 Jun 2025 17:23:16 +0200 Subject: [PATCH 07/20] test: elicitation handling --- .../hooks/__tests__/useConnection.test.tsx | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 676ae87d8..6af4fa3dd 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -4,6 +4,10 @@ import { z } from "zod"; import { ClientRequest } from "@modelcontextprotocol/sdk/types.js"; import { DEFAULT_INSPECTOR_CONFIG } from "../../constants"; import { SSEClientTransportOptions } from "@modelcontextprotocol/sdk/client/sse.js"; +import { + ElicitResult, + ElicitRequest, +} from "@modelcontextprotocol/sdk/types.js"; // Mock fetch global.fetch = jest.fn().mockResolvedValue({ @@ -198,6 +202,252 @@ describe("useConnection", () => { ).rejects.toThrow("MCP client not connected"); }); + describe("Elicitation Support", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("declares elicitation capability during client initialization", async () => { + const Client = jest.requireMock( + "@modelcontextprotocol/sdk/client/index.js", + ).Client; + + const { result } = renderHook(() => useConnection(defaultProps)); + + await act(async () => { + await result.current.connect(); + }); + + expect(Client).toHaveBeenCalledWith( + expect.objectContaining({ + name: "mcp-inspector", + version: expect.any(String), + }), + expect.objectContaining({ + capabilities: expect.objectContaining({ + elicitation: {}, + }), + }), + ); + }); + + test("sets up elicitation request handler when onElicitationRequest is provided", async () => { + const mockOnElicitationRequest = jest.fn(); + const propsWithElicitation = { + ...defaultProps, + onElicitationRequest: mockOnElicitationRequest, + }; + + const { result } = renderHook(() => useConnection(propsWithElicitation)); + + await act(async () => { + await result.current.connect(); + }); + + const elicitRequestHandlerCall = + mockClient.setRequestHandler.mock.calls.find((call) => { + try { + const schema = call[0]; + const testRequest = { + method: "elicitation/create", + params: { + message: "test message", + requestedSchema: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }; + const parseResult = + schema.safeParse && schema.safeParse(testRequest); + return parseResult?.success; + } catch { + return false; + } + }); + + expect(elicitRequestHandlerCall).toBeDefined(); + expect(mockClient.setRequestHandler).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Function), + ); + }); + + test("does not set up elicitation request handler when onElicitationRequest is not provided", async () => { + const { result } = renderHook(() => useConnection(defaultProps)); + + await act(async () => { + await result.current.connect(); + }); + + const elicitRequestHandlerCall = + mockClient.setRequestHandler.mock.calls.find((call) => { + try { + const schema = call[0]; + const testRequest = { + method: "elicitation/create", + params: { + message: "test message", + requestedSchema: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }; + const parseResult = + schema.safeParse && schema.safeParse(testRequest); + return parseResult?.success; + } catch { + return false; + } + }); + + expect(elicitRequestHandlerCall).toBeUndefined(); + }); + + test("elicitation request handler calls onElicitationRequest callback", async () => { + const mockOnElicitationRequest = jest.fn(); + const propsWithElicitation = { + ...defaultProps, + onElicitationRequest: mockOnElicitationRequest, + }; + + const { result } = renderHook(() => useConnection(propsWithElicitation)); + + await act(async () => { + await result.current.connect(); + }); + + const elicitRequestHandlerCall = + mockClient.setRequestHandler.mock.calls.find((call) => { + try { + const schema = call[0]; + const testRequest = { + method: "elicitation/create", + params: { + message: "test message", + requestedSchema: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }; + const parseResult = + schema.safeParse && schema.safeParse(testRequest); + return parseResult?.success; + } catch { + return false; + } + }); + + expect(elicitRequestHandlerCall).toBeDefined(); + const [, handler] = elicitRequestHandlerCall; + + const mockElicitationRequest: ElicitRequest = { + method: "elicitation/create", + params: { + message: "Please provide your name", + requestedSchema: { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + }, + }, + }; + + mockOnElicitationRequest.mockImplementation((_request, resolve) => { + resolve({ action: "accept", content: { name: "test" } }); + }); + + await act(async () => { + await handler(mockElicitationRequest); + }); + + expect(mockOnElicitationRequest).toHaveBeenCalledWith( + mockElicitationRequest, + expect.any(Function), + ); + }); + + test("elicitation request handler returns a promise that resolves with the callback result", async () => { + const mockOnElicitationRequest = jest.fn(); + const propsWithElicitation = { + ...defaultProps, + onElicitationRequest: mockOnElicitationRequest, + }; + + const { result } = renderHook(() => useConnection(propsWithElicitation)); + + await act(async () => { + await result.current.connect(); + }); + + const elicitRequestHandlerCall = + mockClient.setRequestHandler.mock.calls.find((call) => { + try { + const schema = call[0]; + const testRequest = { + method: "elicitation/create", + params: { + message: "test message", + requestedSchema: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }; + const parseResult = + schema.safeParse && schema.safeParse(testRequest); + return parseResult?.success; + } catch { + return false; + } + }); + + const [, handler] = elicitRequestHandlerCall; + + const mockElicitationRequest: ElicitRequest = { + method: "elicitation/create", + params: { + message: "Please provide your name", + requestedSchema: { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + }, + }, + }; + + const mockResponse: ElicitResult = { + action: "accept", + content: { name: "John Doe" }, + }; + + mockOnElicitationRequest.mockImplementation((_request, resolve) => { + resolve(mockResponse); + }); + + let handlerResult; + await act(async () => { + handlerResult = await handler(mockElicitationRequest); + }); + + expect(handlerResult).toEqual(mockResponse); + }); + }); + describe("URL Port Handling", () => { const SSEClientTransport = jest.requireMock( "@modelcontextprotocol/sdk/client/sse.js", From 8053e692cf747a6d473e358d855acd7cce958ac4 Mon Sep 17 00:00:00 2001 From: Alex Andru <58406316+QuantGeekDev@users.noreply.github.com> Date: Fri, 4 Jul 2025 09:42:46 +0200 Subject: [PATCH 08/20] Update package-lock.json Co-authored-by: Cliff Hall --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index e557269ad..12663dd5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@modelcontextprotocol/inspector-cli": "^0.14.3", "@modelcontextprotocol/inspector-client": "^0.14.3", "@modelcontextprotocol/inspector-server": "^0.14.3", - "@modelcontextprotocol/sdk": "^1.13.0", + "@modelcontextprotocol/sdk": "^1.14.0", "concurrently": "^9.0.1", "open": "^10.1.0", "shell-quote": "^1.8.2", From ea97f37ee874beb85a9bc5300bc6475f318e96ba Mon Sep 17 00:00:00 2001 From: Alex Andru <58406316+QuantGeekDev@users.noreply.github.com> Date: Fri, 4 Jul 2025 09:59:37 +0200 Subject: [PATCH 09/20] Update client/package.json Co-authored-by: Cliff Hall --- client/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/package.json b/client/package.json index ffeafcc7e..b25901933 100644 --- a/client/package.json +++ b/client/package.json @@ -23,7 +23,7 @@ "test:watch": "jest --config jest.config.cjs --watch" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.14.0", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", From b4a2a6b7f8e278800ab228fcf55cb82c3051808a Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Fri, 4 Jul 2025 04:48:55 +0200 Subject: [PATCH 10/20] feat: add elicitation request --- client/src/components/ElicitationRequest.tsx | 173 +++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 client/src/components/ElicitationRequest.tsx diff --git a/client/src/components/ElicitationRequest.tsx b/client/src/components/ElicitationRequest.tsx new file mode 100644 index 000000000..dc3f5962e --- /dev/null +++ b/client/src/components/ElicitationRequest.tsx @@ -0,0 +1,173 @@ +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import DynamicJsonForm from "./DynamicJsonForm"; +import JsonView from "./JsonView"; +import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils"; +import { generateDefaultValue } from "@/utils/schemaUtils"; +import { + PendingElicitationRequest, + ElicitationResponse, +} from "./ElicitationTab"; +import Ajv from "ajv"; + +export type ElicitationRequestProps = { + request: PendingElicitationRequest; + onResolve: (id: number, response: ElicitationResponse) => void; +}; + +const ElicitationRequest = ({ + request, + onResolve, +}: ElicitationRequestProps) => { + const [formData, setFormData] = useState({}); + const [validationError, setValidationError] = useState(null); + + useEffect(() => { + const defaultValue = generateDefaultValue(request.request.requestedSchema); + setFormData(defaultValue); + setValidationError(null); + }, [request.request.requestedSchema]); + + const validateEmailFormat = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const validateFormData = ( + data: JsonValue, + schema: JsonSchemaType, + ): boolean => { + if ( + schema.type === "object" && + schema.properties && + typeof data === "object" && + data !== null + ) { + const dataObj = data as Record; + + if (Array.isArray(schema.required)) { + for (const field of schema.required) { + const value = dataObj[field]; + if (value === undefined || value === null || value === "") { + setValidationError(`Required field missing: ${field}`); + return false; + } + } + } + + for (const [fieldName, fieldValue] of Object.entries(dataObj)) { + const fieldSchema = schema.properties[fieldName]; + if ( + fieldSchema && + fieldSchema.format === "email" && + typeof fieldValue === "string" + ) { + if (!validateEmailFormat(fieldValue)) { + setValidationError(`Invalid email format: ${fieldName}`); + return false; + } + } + } + } + + return true; + }; + + const handleAccept = () => { + try { + if (!validateFormData(formData, request.request.requestedSchema)) { + return; + } + + 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; + } + + onResolve(request.id, { + action: "accept", + content: formData as Record, + }); + } catch (error) { + setValidationError( + error instanceof Error ? error.message : "Validation failed", + ); + } + }; + + const handleReject = () => { + onResolve(request.id, { action: "reject" }); + }; + + const handleCancel = () => { + onResolve(request.id, { action: "cancel" }); + }; + + const schemaTitle = + request.request.requestedSchema.title || "Information Request"; + const schemaDescription = request.request.requestedSchema.description; + + return ( +
+
+
+

{schemaTitle}

+

{request.request.message}

+ {schemaDescription && ( +

{schemaDescription}

+ )} +
+
Request Schema:
+ +
+
+
+ +
+
+

Response Form

+ { + setFormData(newValue); + setValidationError(null); + }} + /> + + {validationError && ( +
+
+ Validation Error: {validationError} +
+
+ )} +
+ +
+ + + +
+
+
+ ); +}; + +export default ElicitationRequest; From 0411383d813bfe8cd2f79a8780d0d9d8493a1eef Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Fri, 4 Jul 2025 04:53:55 +0200 Subject: [PATCH 11/20] test: Elicitation request --- .../__tests__/ElicitationRequest.test.tsx | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 client/src/components/__tests__/ElicitationRequest.test.tsx diff --git a/client/src/components/__tests__/ElicitationRequest.test.tsx b/client/src/components/__tests__/ElicitationRequest.test.tsx new file mode 100644 index 000000000..c18fe3eb4 --- /dev/null +++ b/client/src/components/__tests__/ElicitationRequest.test.tsx @@ -0,0 +1,202 @@ +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"; + +jest.mock("../DynamicJsonForm", () => { + return function MockDynamicJsonForm({ + value, + onChange, + }: { + value: unknown; + onChange: (value: unknown) => void; + }) { + return ( +
+ { + try { + const parsed = JSON.parse(e.target.value); + onChange(parsed); + } catch { + onChange(e.target.value); + } + }} + /> +
+ ); + }; +}); + +describe("ElicitationRequest", () => { + const mockOnResolve = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const createMockRequest = ( + overrides: Partial = {}, + ): PendingElicitationRequest => ({ + id: 1, + request: { + id: 1, + message: "Please provide your information", + requestedSchema: { + type: "object", + properties: { + name: { type: "string", description: "Your name" }, + email: { type: "string", format: "email", description: "Your email" }, + }, + required: ["name"], + }, + }, + ...overrides, + }); + + const renderElicitationRequest = ( + request: PendingElicitationRequest = createMockRequest(), + ) => { + return render( + , + ); + }; + + describe("Rendering", () => { + it("should render the component", () => { + renderElicitationRequest(); + expect(screen.getByTestId("elicitation-request")).toBeInTheDocument(); + }); + + it("should display request message", () => { + const message = "Please provide your GitHub username"; + renderElicitationRequest( + createMockRequest({ + request: { + id: 1, + message, + requestedSchema: { + type: "object", + properties: { name: { type: "string" } }, + }, + }, + }), + ); + expect(screen.getByText(message)).toBeInTheDocument(); + }); + + it("should render all three action buttons", () => { + renderElicitationRequest(); + expect( + screen.getByRole("button", { name: /cancel/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /decline/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /submit/i }), + ).toBeInTheDocument(); + }); + + it("should render DynamicJsonForm component", () => { + renderElicitationRequest(); + expect(screen.getByTestId("dynamic-json-form")).toBeInTheDocument(); + }); + }); + + describe("User Interactions", () => { + it("should call onResolve with accept action when Submit button is clicked", async () => { + renderElicitationRequest(); + + const input = screen.getByTestId("form-input"); + await act(async () => { + fireEvent.change(input, { + target: { + value: '{"name": "John Doe", "email": "john@example.com"}', + }, + }); + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /submit/i })); + }); + + expect(mockOnResolve).toHaveBeenCalledWith(1, { + action: "accept", + content: { name: "John Doe", email: "john@example.com" }, + }); + }); + + it("should call onResolve with reject action when Decline button is clicked", async () => { + renderElicitationRequest(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /decline/i })); + }); + + expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "reject" }); + }); + + it("should call onResolve with cancel action when Cancel button is clicked", async () => { + renderElicitationRequest(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + }); + + expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "cancel" }); + }); + }); + + describe("Form Validation", () => { + it("should show validation error for missing required fields", async () => { + renderElicitationRequest(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /submit/i })); + }); + + expect( + screen.getByText(/Required field missing: name/), + ).toBeInTheDocument(); + expect(mockOnResolve).not.toHaveBeenCalledWith( + 1, + expect.objectContaining({ action: "accept" }), + ); + }); + + it("should show validation error for invalid email format", async () => { + renderElicitationRequest(); + + const input = screen.getByTestId("form-input"); + await act(async () => { + fireEvent.change(input, { + target: { value: '{"name": "John", "email": "invalid-email"}' }, + }); + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /submit/i })); + }); + + expect( + screen.getByText(/Invalid email format: email/), + ).toBeInTheDocument(); + expect(mockOnResolve).not.toHaveBeenCalledWith( + 1, + expect.objectContaining({ action: "accept" }), + ); + }); + }); +}); From 9539c16845a0bf960a3935c14fba7ace9f8869e4 Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Fri, 4 Jul 2025 10:54:11 +0200 Subject: [PATCH 12/20] feat: add elicitations tab --- client/src/components/ElicitationTab.tsx | 55 ++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 client/src/components/ElicitationTab.tsx diff --git a/client/src/components/ElicitationTab.tsx b/client/src/components/ElicitationTab.tsx new file mode 100644 index 000000000..0558d5368 --- /dev/null +++ b/client/src/components/ElicitationTab.tsx @@ -0,0 +1,55 @@ +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { TabsContent } from "@/components/ui/tabs"; +import { JsonSchemaType } from "@/utils/jsonUtils"; +import ElicitationRequest from "./ElicitationRequest"; + +export interface ElicitationRequestData { + id: number; + message: string; + requestedSchema: JsonSchemaType; +} + +export interface ElicitationResponse { + action: "accept" | "reject" | "cancel"; + content?: Record; +} + +export type PendingElicitationRequest = { + id: number; + request: ElicitationRequestData; +}; + +export type Props = { + pendingRequests: PendingElicitationRequest[]; + onResolve: (id: number, response: ElicitationResponse) => void; +}; + +const ElicitationTab = ({ pendingRequests, onResolve }: Props) => { + return ( + +
+ + + When the server requests information from the user, requests will + appear here for response. + + +
+

Recent Requests

+ {pendingRequests.map((request) => ( + + ))} + {pendingRequests.length === 0 && ( +

No pending requests

+ )} +
+
+
+ ); +}; + +export default ElicitationTab; From 2baf8bc84b22d9302b150479c0bf7ef70c480d86 Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Fri, 4 Jul 2025 10:54:25 +0200 Subject: [PATCH 13/20] test: elicitations tab --- .../__tests__/ElicitationTab.test.tsx | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 client/src/components/__tests__/ElicitationTab.test.tsx diff --git a/client/src/components/__tests__/ElicitationTab.test.tsx b/client/src/components/__tests__/ElicitationTab.test.tsx new file mode 100644 index 000000000..ca5194619 --- /dev/null +++ b/client/src/components/__tests__/ElicitationTab.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import { Tabs } from "@/components/ui/tabs"; +import ElicitationTab, { PendingElicitationRequest } from "../ElicitationTab"; + +describe("Elicitation tab", () => { + const mockOnResolve = jest.fn(); + + const renderElicitationTab = (pendingRequests: PendingElicitationRequest[]) => + render( + + + , + ); + + it("should render 'No pending requests' when there are no pending requests", () => { + renderElicitationTab([]); + expect( + screen.getByText( + "When the server requests information from the user, requests will appear here for response.", + ), + ).toBeTruthy(); + expect(screen.findByText("No pending requests")).toBeTruthy(); + }); + + it("should render the correct number of requests", () => { + renderElicitationTab( + Array.from({ length: 3 }, (_, i) => ({ + id: i, + request: { + id: i, + message: `Please provide information ${i}`, + requestedSchema: { + type: "object", + properties: { + name: { + type: "string", + description: "Your name", + }, + }, + required: ["name"], + }, + }, + })), + ); + expect(screen.getAllByTestId("elicitation-request").length).toBe(3); + }); +}); From 28c1890117fbcb69c03d2fc9c595ffb8d92145c0 Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Fri, 4 Jul 2025 10:55:00 +0200 Subject: [PATCH 14/20] feat: replace modal logic with new tab request for elicitation --- client/src/App.tsx | 71 +++++++++++++++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 8e5570ae5..fac1e92dc 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -71,9 +71,10 @@ import { initializeInspectorConfig, saveInspectorConfig, } from "./utils/configUtils"; -import ElicitationModal, { - ElicitationRequest, -} from "./components/ElicitationModal"; +import ElicitationTab, { + PendingElicitationRequest, + ElicitationResponse, +} from "./components/ElicitationTab"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; @@ -127,8 +128,14 @@ const App = () => { } > >([]); - const [pendingElicitationRequest, setPendingElicitationRequest] = - useState(null); + const [pendingElicitationRequests, setPendingElicitationRequests] = useState< + Array< + PendingElicitationRequest & { + resolve: (response: ElicitationResponse) => void; + reject: (error: Error) => void; + } + > + >([]); const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); // Auth debugger state @@ -206,12 +213,21 @@ const App = () => { ]); }, onElicitationRequest: (request, resolve) => { - setPendingElicitationRequest({ - id: nextRequestId.current++, - message: request.params.message, - requestedSchema: request.params.requestedSchema, - resolve, - }); + setPendingElicitationRequests((prev) => [ + ...prev, + { + id: nextRequestId.current++, + request: { + id: nextRequestId.current, + message: request.params.message, + requestedSchema: request.params.requestedSchema, + }, + resolve, + reject: (error: Error) => { + console.error("Elicitation request rejected:", error); + }, + }, + ]); }, getRoots: () => rootsRef.current, }); @@ -408,6 +424,17 @@ const App = () => { }); }; + const handleResolveElicitation = ( + id: number, + response: ElicitationResponse, + ) => { + setPendingElicitationRequests((prev) => { + const request = prev.find((r) => r.id === id); + request?.resolve(response); + return prev.filter((r) => r.id !== id); + }); + }; + const clearError = (tabKey: keyof typeof errors) => { setErrors((prev) => ({ ...prev, [tabKey]: null })); }; @@ -599,10 +626,6 @@ const App = () => { setStdErrNotifications([]); }; - const handleCloseElicitationModal = () => { - setPendingElicitationRequest(null); - }; - // Helper component for rendering the AuthDebugger const AuthDebuggerWrapper = () => ( @@ -747,6 +770,15 @@ const App = () => { )} + + + Elicitations + {pendingElicitationRequests.length > 0 && ( + + {pendingElicitationRequests.length} + + )} + Roots @@ -897,6 +929,10 @@ const App = () => { onApprove={handleApproveSampling} onReject={handleRejectSampling} /> + { - - ); }; From f2166c985fa492f92a072fb22b5c4caf22ad651e Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Fri, 4 Jul 2025 10:59:29 +0200 Subject: [PATCH 15/20] refactor: remove dead code --- client/src/components/ElicitationModal.tsx | 181 ------ .../__tests__/ElicitationModal.test.tsx | 535 ------------------ 2 files changed, 716 deletions(-) delete mode 100644 client/src/components/ElicitationModal.tsx delete mode 100644 client/src/components/__tests__/ElicitationModal.test.tsx diff --git a/client/src/components/ElicitationModal.tsx b/client/src/components/ElicitationModal.tsx deleted file mode 100644 index 8a742a313..000000000 --- a/client/src/components/ElicitationModal.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { useState, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import DynamicJsonForm from "./DynamicJsonForm"; -import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils"; -import { generateDefaultValue } from "@/utils/schemaUtils"; -import Ajv from "ajv"; - -export interface ElicitationRequest { - id: number; - message: string; - requestedSchema: JsonSchemaType; - resolve: (response: ElicitationResponse) => void; -} - -export interface ElicitationResponse { - action: "accept" | "reject" | "cancel"; - content?: Record; -} - -interface ElicitationModalProps { - request: ElicitationRequest | null; - onClose: () => void; -} - -const ElicitationModal = ({ request, onClose }: ElicitationModalProps) => { - const [formData, setFormData] = useState({}); - const [validationError, setValidationError] = useState(null); - - useEffect(() => { - if (request) { - const defaultValue = generateDefaultValue(request.requestedSchema); - setFormData(defaultValue); - setValidationError(null); - } - }, [request]); - - if (!request) return null; - - const validateEmailFormat = (email: string): boolean => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email); - }; - - const validateFormData = ( - data: JsonValue, - schema: JsonSchemaType, - ): boolean => { - if ( - schema.type === "object" && - schema.properties && - typeof data === "object" && - data !== null - ) { - const dataObj = data as Record; - - if (Array.isArray(schema.required)) { - for (const field of schema.required) { - const value = dataObj[field]; - if (value === undefined || value === null || value === "") { - setValidationError(`Required field missing: ${field}`); - return false; - } - } - } - - for (const [fieldName, fieldValue] of Object.entries(dataObj)) { - const fieldSchema = schema.properties[fieldName]; - if ( - fieldSchema && - fieldSchema.format === "email" && - typeof fieldValue === "string" - ) { - if (!validateEmailFormat(fieldValue)) { - setValidationError(`Invalid email format: ${fieldName}`); - return false; - } - } - } - } - - return true; - }; - - const handleAccept = () => { - try { - if (!validateFormData(formData, request.requestedSchema)) { - return; - } - - const ajv = new Ajv(); - const validate = ajv.compile(request.requestedSchema); - const isValid = validate(formData); - - if (!isValid) { - const errorMessage = ajv.errorsText(validate.errors); - setValidationError(errorMessage); - return; - } - - request.resolve({ - action: "accept", - content: formData as Record, - }); - onClose(); - } catch (error) { - setValidationError( - error instanceof Error ? error.message : "Validation failed", - ); - } - }; - - const handleReject = () => { - request.resolve({ action: "reject" }); - onClose(); - }; - - const handleCancel = () => { - request.resolve({ action: "cancel" }); - onClose(); - }; - - const schemaTitle = request.requestedSchema.title || "Information Request"; - const schemaDescription = request.requestedSchema.description; - - return ( - - - - {schemaTitle} - - {request.message} - {schemaDescription && ( - - {schemaDescription} - - )} - - - -
- { - setFormData(newValue); - setValidationError(null); - }} - /> - - {validationError && ( -
-
- Validation Error: {validationError} -
-
- )} -
- - - - - - -
-
- ); -}; - -export default ElicitationModal; diff --git a/client/src/components/__tests__/ElicitationModal.test.tsx b/client/src/components/__tests__/ElicitationModal.test.tsx deleted file mode 100644 index 31a06815f..000000000 --- a/client/src/components/__tests__/ElicitationModal.test.tsx +++ /dev/null @@ -1,535 +0,0 @@ -import { render, screen, fireEvent, act } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { describe, it, jest, beforeEach, afterEach } from "@jest/globals"; -import ElicitationModal, { ElicitationRequest } from "../ElicitationModal"; -import { JsonSchemaType } from "@/utils/jsonUtils"; -import * as schemaUtils from "@/utils/schemaUtils"; - -jest.mock("../DynamicJsonForm", () => { - return function MockDynamicJsonForm({ - value, - onChange, - }: { - value: unknown; - onChange: (value: unknown) => void; - }) { - return ( -
- { - try { - const parsed = JSON.parse(e.target.value); - onChange(parsed); - } catch { - onChange(e.target.value); - } - }} - /> -
- ); - }; -}); - -jest.mock("@/components/ui/dialog", () => ({ - Dialog: ({ - children, - open, - onOpenChange, - }: { - children: React.ReactNode; - open: boolean; - onOpenChange: () => void; - }) => - open ? ( -
- {children} -
- ) : null, - DialogContent: ({ - children, - className, - }: { - children: React.ReactNode; - className?: string; - }) => ( -
- {children} -
- ), - DialogHeader: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - DialogTitle: ({ children }: { children: React.ReactNode }) => ( -

{children}

- ), - DialogDescription: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - DialogFooter: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})); - -jest.mock("@/components/ui/button", () => ({ - Button: ({ - children, - onClick, - variant, - ...props - }: { - children: React.ReactNode; - onClick?: () => void; - variant?: string; - [key: string]: unknown; - }) => ( - - ), -})); - -jest.mock("@/utils/schemaUtils", () => ({ - generateDefaultValue: jest.fn((schema: JsonSchemaType) => { - if (schema.type === "object" && schema.properties) { - const defaults: Record = {}; - Object.keys(schema.properties).forEach((key) => { - const prop = schema.properties![key]; - if (prop.type === "string") defaults[key] = ""; - if (prop.type === "number") defaults[key] = 0; - if (prop.type === "boolean") defaults[key] = false; - }); - return defaults; - } - return {}; - }), -})); - -describe("ElicitationModal", () => { - const mockResolve = jest.fn(); - const mockOnClose = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - const createMockRequest = ( - overrides: Partial = {}, - ): ElicitationRequest => ({ - id: 1, - message: "Please provide your information", - requestedSchema: { - type: "object", - properties: { - name: { type: "string", description: "Your name" }, - email: { type: "string", format: "email", description: "Your email" }, - }, - required: ["name"], - }, - resolve: mockResolve, - ...overrides, - }); - - const renderElicitationModal = ( - request: ElicitationRequest | null = null, - ) => { - return render( - , - ); - }; - - describe("Rendering", () => { - it("should render null when no request is provided", () => { - render(); - expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); - }); - - it("should render dialog when request is provided", () => { - renderElicitationModal(); - expect(screen.getByTestId("dialog")).toBeInTheDocument(); - expect(screen.getByTestId("dialog-content")).toBeInTheDocument(); - }); - - it("should display request message", () => { - const message = "Please provide your GitHub username"; - renderElicitationModal(createMockRequest({ message })); - expect(screen.getByText(message)).toBeInTheDocument(); - }); - - it("should display schema title when provided", () => { - const request = createMockRequest({ - requestedSchema: { - type: "object", - title: "User Information", - properties: { name: { type: "string" } }, - }, - }); - renderElicitationModal(request); - expect(screen.getByText("User Information")).toBeInTheDocument(); - }); - - it("should display default title when schema title is not provided", () => { - renderElicitationModal(); - expect(screen.getByText("Information Request")).toBeInTheDocument(); - }); - - it("should display schema description when provided", () => { - const description = "Please fill out your contact details"; - const request = createMockRequest({ - requestedSchema: { - type: "object", - description, - properties: { name: { type: "string" } }, - }, - }); - renderElicitationModal(request); - expect(screen.getByText(description)).toBeInTheDocument(); - }); - - it("should render all three action buttons", () => { - renderElicitationModal(); - expect(screen.getByTestId("button-cancel")).toBeInTheDocument(); - expect(screen.getByTestId("button-decline")).toBeInTheDocument(); - expect(screen.getByTestId("button-submit")).toBeInTheDocument(); - }); - - it("should render DynamicJsonForm component", () => { - renderElicitationModal(); - expect(screen.getByTestId("dynamic-json-form")).toBeInTheDocument(); - }); - }); - - describe("User Interactions", () => { - it("should call resolve with accept action when Submit button is clicked", async () => { - renderElicitationModal(); - - const input = screen.getByTestId("form-input"); - await act(async () => { - fireEvent.change(input, { - target: { - value: '{"name": "John Doe", "email": "john@example.com"}', - }, - }); - }); - - await act(async () => { - fireEvent.click(screen.getByTestId("button-submit")); - }); - - expect(mockResolve).toHaveBeenCalledWith({ - action: "accept", - content: { name: "John Doe", email: "john@example.com" }, - }); - expect(mockOnClose).toHaveBeenCalled(); - }); - - it("should call resolve with reject action when Decline button is clicked", async () => { - renderElicitationModal(); - - await act(async () => { - fireEvent.click(screen.getByTestId("button-decline")); - }); - - expect(mockResolve).toHaveBeenCalledWith({ action: "reject" }); - expect(mockOnClose).toHaveBeenCalled(); - }); - - it("should call resolve with cancel action when Cancel button is clicked", async () => { - renderElicitationModal(); - - await act(async () => { - fireEvent.click(screen.getByTestId("button-cancel")); - }); - - expect(mockResolve).toHaveBeenCalledWith({ action: "cancel" }); - expect(mockOnClose).toHaveBeenCalled(); - }); - - it("should call resolve with cancel action when dialog is closed", async () => { - renderElicitationModal(); - - await act(async () => { - fireEvent.click(screen.getByTestId("dialog")); - }); - - expect(mockResolve).toHaveBeenCalledWith({ action: "cancel" }); - expect(mockOnClose).toHaveBeenCalled(); - }); - }); - - describe("Form Validation", () => { - it("should show validation error for missing required fields", async () => { - renderElicitationModal(); - - await act(async () => { - fireEvent.click(screen.getByTestId("button-submit")); - }); - - expect( - screen.getByText(/Required field missing: name/), - ).toBeInTheDocument(); - expect(mockResolve).not.toHaveBeenCalledWith( - expect.objectContaining({ action: "accept" }), - ); - }); - - it("should show validation error for invalid email format", async () => { - renderElicitationModal(); - - const input = screen.getByTestId("form-input"); - await act(async () => { - fireEvent.change(input, { - target: { value: '{"name": "John", "email": "invalid-email"}' }, - }); - }); - - await act(async () => { - fireEvent.click(screen.getByTestId("button-submit")); - }); - - expect( - screen.getByText(/Invalid email format: email/), - ).toBeInTheDocument(); - expect(mockResolve).not.toHaveBeenCalledWith( - expect.objectContaining({ action: "accept" }), - ); - }); - - it("should clear validation error when form data changes", async () => { - renderElicitationModal(); - - await act(async () => { - fireEvent.click(screen.getByTestId("button-submit")); - }); - - expect(screen.getByText(/Required field missing/)).toBeInTheDocument(); - - const input = screen.getByTestId("form-input"); - await act(async () => { - fireEvent.change(input, { target: { value: '{"name": "John"}' } }); - }); - - expect( - screen.queryByText(/Required field missing/), - ).not.toBeInTheDocument(); - }); - - it("should show AJV validation errors", async () => { - const mockAjv = { - compile: jest.fn(() => ({ - errors: [{ instancePath: "/name", message: "should be string" }], - })), - errorsText: jest.fn(() => "data/name should be string"), - }; - - jest.doMock("ajv", () => jest.fn(() => mockAjv)); - - renderElicitationModal(); - - const input = screen.getByTestId("form-input"); - await act(async () => { - fireEvent.change(input, { target: { value: '{"name": "John"}' } }); - }); - - await act(async () => { - fireEvent.click(screen.getByTestId("button-submit")); - }); - }); - }); - - describe("Email Validation", () => { - it("should accept valid email formats", async () => { - const validEmails = [ - "test@example.com", - "user.name@domain.co.uk", - "user+tag@example.org", - ]; - - for (const email of validEmails) { - renderElicitationModal(); - - const input = screen.getByTestId("form-input"); - await act(async () => { - fireEvent.change(input, { - target: { value: `{"name": "John", "email": "${email}"}` }, - }); - }); - - await act(async () => { - fireEvent.click(screen.getByTestId("button-submit")); - }); - - expect(mockResolve).toHaveBeenCalledWith({ - action: "accept", - content: { name: "John", email }, - }); - - jest.clearAllMocks(); - document.body.innerHTML = ""; - } - }); - - it("should reject invalid email formats", async () => { - const invalidEmails = [ - "invalid-email", - "@example.com", - "user@", - "user.example.com", - ]; - - for (const email of invalidEmails) { - renderElicitationModal(); - - const input = screen.getByTestId("form-input"); - await act(async () => { - fireEvent.change(input, { - target: { value: `{"name": "John", "email": "${email}"}` }, - }); - }); - - await act(async () => { - fireEvent.click(screen.getByTestId("button-submit")); - }); - - expect( - screen.getByText(/Invalid email format: email/), - ).toBeInTheDocument(); - expect(mockResolve).not.toHaveBeenCalledWith( - expect.objectContaining({ action: "accept" }), - ); - - jest.clearAllMocks(); - document.body.innerHTML = ""; - } - }); - }); - - describe("Schema Types", () => { - it("should handle string schema", () => { - const request = createMockRequest({ - requestedSchema: { - type: "object", - properties: { - username: { type: "string", description: "Username" }, - }, - }, - }); - renderElicitationModal(request); - expect(screen.getByTestId("dynamic-json-form")).toBeInTheDocument(); - }); - - it("should handle number schema", () => { - const request = createMockRequest({ - requestedSchema: { - type: "object", - properties: { - age: { type: "number", minimum: 0, maximum: 120 }, - }, - }, - }); - renderElicitationModal(request); - expect(screen.getByTestId("dynamic-json-form")).toBeInTheDocument(); - }); - - it("should handle boolean schema", () => { - const request = createMockRequest({ - requestedSchema: { - type: "object", - properties: { - subscribe: { - type: "boolean", - description: "Subscribe to newsletter", - }, - }, - }, - }); - renderElicitationModal(request); - expect(screen.getByTestId("dynamic-json-form")).toBeInTheDocument(); - }); - - it("should handle enum schema", () => { - const request = createMockRequest({ - requestedSchema: { - type: "object", - properties: { - country: { - type: "string", - enum: ["US", "UK", "CA"], - enumNames: ["United States", "United Kingdom", "Canada"], - }, - }, - }, - }); - renderElicitationModal(request); - expect(screen.getByTestId("dynamic-json-form")).toBeInTheDocument(); - }); - }); - - describe("Error Handling", () => { - it("should handle validation errors gracefully", async () => { - renderElicitationModal(); - - await act(async () => { - fireEvent.click(screen.getByTestId("button-submit")); - }); - - expect(screen.getByText(/Validation Error/)).toBeInTheDocument(); - }); - - it("should handle JSON parsing errors in form validation", async () => { - renderElicitationModal(); - - const input = screen.getByTestId("form-input"); - await act(async () => { - fireEvent.change(input, { target: { value: "invalid json" } }); - }); - - await act(async () => { - fireEvent.click(screen.getByTestId("button-submit")); - }); - - expect(mockResolve).not.toHaveBeenCalledWith( - expect.objectContaining({ action: "accept" }), - ); - }); - }); - - describe("Default Values", () => { - it("should generate default values when request changes", () => { - const { rerender } = renderElicitationModal(); - - const newRequest = createMockRequest({ - id: 2, - requestedSchema: { - type: "object", - properties: { - newField: { type: "string" }, - }, - }, - }); - - rerender(); - - expect(schemaUtils.generateDefaultValue).toHaveBeenCalledWith( - newRequest.requestedSchema, - ); - }); - }); -}); From e6c8b946aa8982d99aec2ebfe172129cccc2255f Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Fri, 4 Jul 2025 12:09:51 +0200 Subject: [PATCH 16/20] feat: use decline instead of reject --- client/src/App.tsx | 4 ++-- client/src/components/ElicitationRequest.tsx | 6 +++--- client/src/components/ElicitationTab.tsx | 2 +- client/src/components/__tests__/ElicitationRequest.test.tsx | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index fac1e92dc..54145d945 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -132,7 +132,7 @@ const App = () => { Array< PendingElicitationRequest & { resolve: (response: ElicitationResponse) => void; - reject: (error: Error) => void; + decline: (error: Error) => void; } > >([]); @@ -223,7 +223,7 @@ const App = () => { requestedSchema: request.params.requestedSchema, }, resolve, - reject: (error: Error) => { + decline: (error: Error) => { console.error("Elicitation request rejected:", error); }, }, diff --git a/client/src/components/ElicitationRequest.tsx b/client/src/components/ElicitationRequest.tsx index dc3f5962e..4488a9620 100644 --- a/client/src/components/ElicitationRequest.tsx +++ b/client/src/components/ElicitationRequest.tsx @@ -100,8 +100,8 @@ const ElicitationRequest = ({ } }; - const handleReject = () => { - onResolve(request.id, { action: "reject" }); + const handleDecline = () => { + onResolve(request.id, { action: "decline" }); }; const handleCancel = () => { @@ -158,7 +158,7 @@ const ElicitationRequest = ({ -