From e163aea0e078790ee46cbf4f7a4603bc627cea58 Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Thu, 19 Jun 2025 15:03:39 +0200 Subject: [PATCH 01/34] 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 9009e698..01c9a0ac 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/34] 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 6a5993c3..77023b28 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 afd435d7..2a0da799 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/34] 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 37a0675b..ffeafcc7 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 00000000..8a742a31 --- /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 00000000..31a06815 --- /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/34] 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 28cbf303..9ba5ceef 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/34] 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 055e1dfd..92a925a8 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/34] 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 26eb44c5..8e5570ae 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/34] 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 676ae87d..6af4fa3d 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/34] 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 e557269a..12663dd5 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/34] 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 ffeafcc7..b2590193 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/34] 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 00000000..dc3f5962 --- /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/34] 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 00000000..c18fe3eb --- /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/34] 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 00000000..0558d536 --- /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/34] 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 00000000..ca519461 --- /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/34] 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 8e5570ae..fac1e92d 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/34] 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 8a742a31..00000000 --- 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 31a06815..00000000 --- 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/34] 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 fac1e92d..54145d94 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 dc3f5962..4488a962 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 = ({ - + {showOauthConfig && ( +
+ + setOauthClientId(e.target.value)} + value={oauthClientId} + data-testid="oauth-client-id-input" + className="font-mono" + /> + + + + setOauthScope(e.target.value)} + value={oauthScope} + data-testid="oauth-scope-input" + className="font-mono" + /> + + setOauthResource(e.target.value)} + value={oauthResource} + data-testid="oauth-resource-input" + className="font-mono" + /> +
+ )} + )} diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index d818bdbb..58c6eedf 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -42,6 +42,12 @@ describe("Sidebar Environment Variables", () => { setArgs: jest.fn(), sseUrl: "", setSseUrl: jest.fn(), + oauthClientId: "", + setOauthClientId: jest.fn(), + oauthScope: "", + setOauthScope: jest.fn(), + oauthResource: "", + setOauthResource: jest.fn(), env: {}, setEnv: jest.fn(), bearerToken: "", diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 3e3516e0..aa391a0e 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -9,8 +9,67 @@ import { } from "@modelcontextprotocol/sdk/shared/auth.js"; import { SESSION_KEYS, getServerSpecificKey } from "./constants"; +export const getClientInformationFromSessionStorage = async ({ + serverUrl, + isPreregistered, +}: { + serverUrl: string; + isPreregistered?: boolean; +}) => { + const key = getServerSpecificKey( + isPreregistered + ? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION + : SESSION_KEYS.CLIENT_INFORMATION, + serverUrl, + ); + + const value = sessionStorage.getItem(key); + if (!value) { + return undefined; + } + + return await OAuthClientInformationSchema.parseAsync(JSON.parse(value)); +}; + +export const saveClientInformationToSessionStorage = ({ + serverUrl, + clientInformation, + isPreregistered, +}: { + serverUrl: string; + clientInformation: OAuthClientInformation; + isPreregistered?: boolean; +}) => { + const key = getServerSpecificKey( + isPreregistered + ? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION + : SESSION_KEYS.CLIENT_INFORMATION, + serverUrl, + ); + sessionStorage.setItem(key, JSON.stringify(clientInformation)); +}; + +export const clearClientInformationFromSessionStorage = ({ + serverUrl, + isPreregistered, +}: { + serverUrl: string; + isPreregistered?: boolean; +}) => { + const key = getServerSpecificKey( + isPreregistered + ? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION + : SESSION_KEYS.CLIENT_INFORMATION, + serverUrl, + ); + sessionStorage.removeItem(key); +}; + export class InspectorOAuthClientProvider implements OAuthClientProvider { - constructor(public serverUrl: string) { + constructor( + protected serverUrl: string, + protected resource?: string, + ) { // Save the server URL to session storage sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl); } @@ -31,24 +90,29 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { } async clientInformation() { - const key = getServerSpecificKey( - SESSION_KEYS.CLIENT_INFORMATION, - this.serverUrl, - ); - const value = sessionStorage.getItem(key); - if (!value) { - return undefined; - } + // Try to get the preregistered client information from session storage first + const preregisteredClientInformation = await getClientInformationFromSessionStorage({ + serverUrl: this.serverUrl, + isPreregistered: true, + }); - return await OAuthClientInformationSchema.parseAsync(JSON.parse(value)); + // If no preregistered client information is found, get the dynamically registered client information + return ( + preregisteredClientInformation ?? + (await getClientInformationFromSessionStorage({ + serverUrl: this.serverUrl, + isPreregistered: false, + })) + ); } saveClientInformation(clientInformation: OAuthClientInformation) { - const key = getServerSpecificKey( - SESSION_KEYS.CLIENT_INFORMATION, - this.serverUrl, - ); - sessionStorage.setItem(key, JSON.stringify(clientInformation)); + // Save the dynamically registered client information to session storage + saveClientInformationToSessionStorage({ + serverUrl: this.serverUrl, + clientInformation, + isPreregistered: false, + }); } async tokens() { @@ -67,6 +131,18 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { } redirectToAuthorization(authorizationUrl: URL) { + /** + * Note: This resource parameter is for testing purposes in Inspector. + * Once MCP Client SDK supports resource indicators, this parameter + * will be passed to the SDK's auth method similar to how scope is passed. + * + * See: https://github.com/modelcontextprotocol/typescript-sdk/pull/498 + * + * TODO: @xiaoyijun Remove this once MCP Client SDK supports resource indicators. + */ + if (this.resource) { + authorizationUrl.searchParams.set("resource", this.resource); + } window.location.href = authorizationUrl.href; } @@ -92,9 +168,10 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { } clear() { - sessionStorage.removeItem( - getServerSpecificKey(SESSION_KEYS.CLIENT_INFORMATION, this.serverUrl), - ); + clearClientInformationFromSessionStorage({ + serverUrl: this.serverUrl, + isPreregistered: false, + }); sessionStorage.removeItem( getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl), ); diff --git a/client/src/lib/constants.ts b/client/src/lib/constants.ts index 922f1943..2e983302 100644 --- a/client/src/lib/constants.ts +++ b/client/src/lib/constants.ts @@ -6,6 +6,7 @@ export const SESSION_KEYS = { SERVER_URL: "mcp_server_url", TOKENS: "mcp_tokens", CLIENT_INFORMATION: "mcp_client_information", + PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information", SERVER_METADATA: "mcp_server_metadata", AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state", } as const; diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 9009e698..47e593ac 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -30,13 +30,13 @@ import { Progress, } from "@modelcontextprotocol/sdk/types.js"; import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useToast } from "@/lib/hooks/useToast"; import { z } from "zod"; import { ConnectionStatus } from "../constants"; import { Notification, StdErrNotificationSchema } from "../notificationTypes"; import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; -import { InspectorOAuthClientProvider } from "../auth"; +import { clearClientInformationFromSessionStorage, InspectorOAuthClientProvider, saveClientInformationToSessionStorage } from "../auth"; import packageJson from "../../../package.json"; import { getMCPProxyAddress, @@ -56,6 +56,9 @@ interface UseConnectionOptions { env: Record; bearerToken?: string; headerName?: string; + oauthClientId?: string; + oauthScope?: string; + oauthResource?: string; config: InspectorConfig; onNotification?: (notification: Notification) => void; onStdErrNotification?: (notification: Notification) => void; @@ -73,6 +76,9 @@ export function useConnection({ env, bearerToken, headerName, + oauthClientId, + oauthScope, + oauthResource, config, onNotification, onStdErrNotification, @@ -93,6 +99,22 @@ export function useConnection({ >([]); const [completionsSupported, setCompletionsSupported] = useState(true); + useEffect(() => { + if (!oauthClientId) { + clearClientInformationFromSessionStorage({ + serverUrl: sseUrl, + isPreregistered: true, + }); + return; + } + + saveClientInformationToSessionStorage({ + serverUrl: sseUrl, + clientInformation: { client_id: oauthClientId }, + isPreregistered: true, + }); + }, [oauthClientId, sseUrl]); + const pushHistory = (request: object, response?: object) => { setRequestHistory((prev) => [ ...prev, @@ -277,9 +299,15 @@ export function useConnection({ const handleAuthError = async (error: unknown) => { if (is401Error(error)) { - const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); + const serverAuthProvider = new InspectorOAuthClientProvider( + sseUrl, + oauthResource, + ); - const result = await auth(serverAuthProvider, { serverUrl: sseUrl }); + const result = await auth(serverAuthProvider, { + serverUrl: sseUrl, + scope: oauthScope, + }); return result === "AUTHORIZED"; } @@ -315,7 +343,10 @@ export function useConnection({ const headers: HeadersInit = {}; // Create an auth provider with the current server URL - const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); + const serverAuthProvider = new InspectorOAuthClientProvider( + sseUrl, + oauthResource, + ); // Use manually provided bearer token if available, otherwise use OAuth tokens const token = @@ -535,7 +566,10 @@ export function useConnection({ clientTransport as StreamableHTTPClientTransport ).terminateSession(); await mcpClient?.close(); - const authProvider = new InspectorOAuthClientProvider(sseUrl); + const authProvider = new InspectorOAuthClientProvider( + sseUrl, + oauthResource, + ); authProvider.clear(); setMcpClient(null); setClientTransport(null); From 3b3205228ff105163ba89486723e142817af0162 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Tue, 8 Jul 2025 10:21:40 +0800 Subject: [PATCH 21/34] refactor: remove resource config --- client/src/App.tsx | 11 ----------- client/src/components/Sidebar.tsx | 12 ------------ .../src/components/__tests__/Sidebar.test.tsx | 2 -- client/src/lib/auth.ts | 18 ++---------------- client/src/lib/hooks/useConnection.ts | 13 ++----------- 5 files changed, 4 insertions(+), 52 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index edcace8d..06b208b1 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -124,10 +124,6 @@ const App = () => { return localStorage.getItem("lastOauthScope") || ""; }); - const [oauthResource, setOauthResource] = useState(() => { - return localStorage.getItem("lastOauthResource") || ""; - }); - const [pendingSampleRequests, setPendingSampleRequests] = useState< Array< PendingRequest & { @@ -198,7 +194,6 @@ const App = () => { headerName, oauthClientId, oauthScope, - oauthResource, config, onNotification: (notification) => { setNotifications((prev) => [...prev, notification as ServerNotification]); @@ -250,10 +245,6 @@ const App = () => { localStorage.setItem("lastOauthScope", oauthScope); }, [oauthScope]); - useEffect(() => { - localStorage.setItem("lastOauthResource", oauthResource); - }, [oauthResource]); - useEffect(() => { saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config); }, [config]); @@ -681,8 +672,6 @@ const App = () => { setOauthClientId={setOauthClientId} oauthScope={oauthScope} setOauthScope={setOauthScope} - oauthResource={oauthResource} - setOauthResource={setOauthResource} onConnect={connectMcpServer} onDisconnect={disconnectMcpServer} stdErrNotifications={stdErrNotifications} diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index badbd679..972aef72 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -60,8 +60,6 @@ interface SidebarProps { setOauthClientId: (id: string) => void; oauthScope: string; setOauthScope: (scope: string) => void; - oauthResource: string; - setOauthResource: (resource: string) => void; onConnect: () => void; onDisconnect: () => void; stdErrNotifications: StdErrNotification[]; @@ -93,8 +91,6 @@ const Sidebar = ({ setOauthClientId, oauthScope, setOauthScope, - oauthResource, - setOauthResource, onConnect, onDisconnect, stdErrNotifications, @@ -409,14 +405,6 @@ const Sidebar = ({ data-testid="oauth-scope-input" className="font-mono" /> - - setOauthResource(e.target.value)} - value={oauthResource} - data-testid="oauth-resource-input" - className="font-mono" - /> )} diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 58c6eedf..e892a7f8 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -46,8 +46,6 @@ describe("Sidebar Environment Variables", () => { setOauthClientId: jest.fn(), oauthScope: "", setOauthScope: jest.fn(), - oauthResource: "", - setOauthResource: jest.fn(), env: {}, setEnv: jest.fn(), bearerToken: "", diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index aa391a0e..9450a3a5 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -66,10 +66,8 @@ export const clearClientInformationFromSessionStorage = ({ }; export class InspectorOAuthClientProvider implements OAuthClientProvider { - constructor( - protected serverUrl: string, - protected resource?: string, - ) { + + constructor(protected serverUrl: string) { // Save the server URL to session storage sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl); } @@ -131,18 +129,6 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { } redirectToAuthorization(authorizationUrl: URL) { - /** - * Note: This resource parameter is for testing purposes in Inspector. - * Once MCP Client SDK supports resource indicators, this parameter - * will be passed to the SDK's auth method similar to how scope is passed. - * - * See: https://github.com/modelcontextprotocol/typescript-sdk/pull/498 - * - * TODO: @xiaoyijun Remove this once MCP Client SDK supports resource indicators. - */ - if (this.resource) { - authorizationUrl.searchParams.set("resource", this.resource); - } window.location.href = authorizationUrl.href; } diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 47e593ac..245c21fd 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -58,7 +58,6 @@ interface UseConnectionOptions { headerName?: string; oauthClientId?: string; oauthScope?: string; - oauthResource?: string; config: InspectorConfig; onNotification?: (notification: Notification) => void; onStdErrNotification?: (notification: Notification) => void; @@ -78,7 +77,6 @@ export function useConnection({ headerName, oauthClientId, oauthScope, - oauthResource, config, onNotification, onStdErrNotification, @@ -301,7 +299,6 @@ export function useConnection({ if (is401Error(error)) { const serverAuthProvider = new InspectorOAuthClientProvider( sseUrl, - oauthResource, ); const result = await auth(serverAuthProvider, { @@ -343,10 +340,7 @@ export function useConnection({ const headers: HeadersInit = {}; // Create an auth provider with the current server URL - const serverAuthProvider = new InspectorOAuthClientProvider( - sseUrl, - oauthResource, - ); + const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); // Use manually provided bearer token if available, otherwise use OAuth tokens const token = @@ -566,10 +560,7 @@ export function useConnection({ clientTransport as StreamableHTTPClientTransport ).terminateSession(); await mcpClient?.close(); - const authProvider = new InspectorOAuthClientProvider( - sseUrl, - oauthResource, - ); + const authProvider = new InspectorOAuthClientProvider(sseUrl); authProvider.clear(); setMcpClient(null); setClientTransport(null); From de474e54114a427cb14086466450429d06c6fcf3 Mon Sep 17 00:00:00 2001 From: Dennis Geurts Date: Sat, 12 Jul 2025 12:42:21 +0200 Subject: [PATCH 22/34] feat: Add context argument to completion handlers This commit adds a `context` argument to the completion handlers in `PromptsTab`, `ResourcesTab`, `useCompletionState`, and `useConnection`. The context argument allows passing additional information to the completion engine, which can be used to improve the quality of the completions. The context is passed as a dictionary of key-value pairs. The `ResourcesTab` component was also updated to pass `templateValues` as the `context` to the completion handler. The `useConnection` hook was updated to include the context in the completion request sent to the server. --- client/src/components/PromptsTab.tsx | 1 + client/src/components/ResourcesTab.tsx | 2 ++ client/src/lib/hooks/useCompletionState.ts | 6 ++++++ client/src/lib/hooks/useConnection.ts | 7 +++++++ 4 files changed, 16 insertions(+) diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx index 466b3d52..c0e188c0 100644 --- a/client/src/components/PromptsTab.tsx +++ b/client/src/components/PromptsTab.tsx @@ -48,6 +48,7 @@ const PromptsTab = ({ ref: PromptReference | ResourceReference, argName: string, value: string, + context?: Record, ) => Promise; completionsSupported: boolean; promptContent: string; diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index 8ec3f91b..874e0ff7 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -52,6 +52,7 @@ const ResourcesTab = ({ ref: ResourceReference | PromptReference, argName: string, value: string, + context?: Record, ) => Promise; completionsSupported: boolean; resourceContent: string; @@ -94,6 +95,7 @@ const ResourcesTab = ({ }, key, value, + templateValues, ); } }; diff --git a/client/src/lib/hooks/useCompletionState.ts b/client/src/lib/hooks/useCompletionState.ts index 71492e31..a63968ae 100644 --- a/client/src/lib/hooks/useCompletionState.ts +++ b/client/src/lib/hooks/useCompletionState.ts @@ -28,6 +28,7 @@ export function useCompletionState( ref: ResourceReference | PromptReference, argName: string, value: string, + context?: Record, signal?: AbortSignal, ) => Promise, completionsSupported: boolean = true, @@ -66,6 +67,7 @@ export function useCompletionState( ref: ResourceReference | PromptReference, argName: string, value: string, + context?: Record, ) => { if (!completionsSupported) { return; @@ -82,10 +84,13 @@ export function useCompletionState( })); try { + console.log(state); + const values = await handleCompletion( ref, argName, value, + context, abortController.signal, ); @@ -97,6 +102,7 @@ export function useCompletionState( })); } } catch { + console.error("completion failed"); if (!abortController.signal.aborted) { setState((prev) => ({ ...prev, diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 5e592bde..97c36fdd 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -170,6 +170,7 @@ export function useConnection({ ref: ResourceReference | PromptReference, argName: string, value: string, + context?: Record, signal?: AbortSignal, ): Promise => { if (!mcpClient || !completionsSupported) { @@ -187,6 +188,12 @@ export function useConnection({ }, }; + if (context) { + request["params"]["context"] = { + arguments: context, + }; + } + try { const response = await makeRequest(request, CompleteResultSchema, { signal, From e2de80fd84629715445cccd011a994c96f3c6443 Mon Sep 17 00:00:00 2001 From: Dennis Geurts Date: Sat, 12 Jul 2025 12:45:02 +0200 Subject: [PATCH 23/34] feat: Removes prompt argument from completion state This commit removes the prompt argument from the completion state. The argument is deleted from the context after the completion is handled. This prevents unexpected behavior and ensures a cleaner state. --- client/src/components/PromptsTab.tsx | 1 + client/src/lib/hooks/useCompletionState.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx index c0e188c0..fa590558 100644 --- a/client/src/components/PromptsTab.tsx +++ b/client/src/components/PromptsTab.tsx @@ -74,6 +74,7 @@ const PromptsTab = ({ }, argName, value, + promptArgs, ); } }; diff --git a/client/src/lib/hooks/useCompletionState.ts b/client/src/lib/hooks/useCompletionState.ts index a63968ae..26b69a27 100644 --- a/client/src/lib/hooks/useCompletionState.ts +++ b/client/src/lib/hooks/useCompletionState.ts @@ -84,7 +84,9 @@ export function useCompletionState( })); try { - console.log(state); + if (context !== undefined) { + delete context[argName]; + } const values = await handleCompletion( ref, From 9e8042949d9f003080c60db457ee387471c899bb Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Mon, 14 Jul 2025 17:22:38 +0800 Subject: [PATCH 24/34] refactor: update label for redirect URL --- client/src/components/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 972aef72..f4797098 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -389,7 +389,7 @@ const Sidebar = ({ className="font-mono" /> Date: Fri, 18 Jul 2025 18:21:40 +0200 Subject: [PATCH 25/34] feat: add active tab system --- client/src/App.tsx | 157 +++++++++++++++++------ client/src/components/ElicitationTab.tsx | 1 + 2 files changed, 116 insertions(+), 42 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 54145d94..96352dd1 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -138,11 +138,9 @@ const App = () => { >([]); const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); - // Auth debugger state const [authState, setAuthState] = useState(EMPTY_DEBUGGER_STATE); - // Helper function to update specific auth state properties const updateAuthState = (updates: Partial) => { setAuthState((prev) => ({ ...prev, ...updates })); }; @@ -170,6 +168,19 @@ const App = () => { const [nextToolCursor, setNextToolCursor] = useState(); const progressTokenRef = useRef(0); + const [activeTab, setActiveTab] = useState(() => { + const hash = window.location.hash.slice(1); + const initialTab = hash || "resources"; + return initialTab; + }); + + const currentTabRef = useRef(activeTab); + const lastToolCallOriginTabRef = useRef(activeTab); + + useEffect(() => { + currentTabRef.current = activeTab; + }, [activeTab]); + const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300); const { width: sidebarWidth, @@ -213,6 +224,8 @@ const App = () => { ]); }, onElicitationRequest: (request, resolve) => { + const currentTab = lastToolCallOriginTabRef.current; + setPendingElicitationRequests((prev) => [ ...prev, { @@ -222,16 +235,52 @@ const App = () => { message: request.params.message, requestedSchema: request.params.requestedSchema, }, + originatingTab: currentTab, resolve, decline: (error: Error) => { console.error("Elicitation request rejected:", error); }, }, ]); + + setActiveTab("elicitations"); + window.location.hash = "elicitations"; }, getRoots: () => rootsRef.current, }); + useEffect(() => { + if (serverCapabilities) { + const hash = window.location.hash.slice(1); + + const validTabs = [ + ...(serverCapabilities?.resources ? ["resources"] : []), + ...(serverCapabilities?.prompts ? ["prompts"] : []), + ...(serverCapabilities?.tools ? ["tools"] : []), + "ping", + "sampling", + "elicitations", + "roots", + "auth", + ]; + + const isValidTab = validTabs.includes(hash); + + if (!isValidTab) { + const defaultTab = serverCapabilities?.resources + ? "resources" + : serverCapabilities?.prompts + ? "prompts" + : serverCapabilities?.tools + ? "tools" + : "ping"; + + setActiveTab(defaultTab); + window.location.hash = defaultTab; + } + } + }, [serverCapabilities]); + useEffect(() => { localStorage.setItem("lastCommand", command); }, [command]); @@ -260,7 +309,6 @@ const App = () => { saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config); }, [config]); - // Auto-connect to previously saved serverURL after OAuth callback const onOAuthConnect = useCallback( (serverUrl: string) => { setSseUrl(serverUrl); @@ -270,7 +318,6 @@ const App = () => { [connectMcpServer], ); - // Update OAuth debug state during debug callback const onOAuthDebugConnect = useCallback( async ({ authorizationCode, @@ -291,7 +338,6 @@ const App = () => { } if (restoredState && authorizationCode) { - // Restore the previous auth state and continue the OAuth flow let currentState: AuthDebuggerState = { ...restoredState, authorizationCode, @@ -302,12 +348,10 @@ const App = () => { }; try { - // Create a new state machine instance to continue the flow const stateMachine = new OAuthStateMachine(sseUrl, (updates) => { currentState = { ...currentState, ...updates }; }); - // Continue stepping through the OAuth flow from where we left off while ( currentState.oauthStep !== "complete" && currentState.oauthStep !== "authorization_code" @@ -316,7 +360,6 @@ const App = () => { } if (currentState.oauthStep === "complete") { - // After the flow completes or reaches a user-input step, update the app state updateAuthState({ ...currentState, statusMessage: { @@ -339,7 +382,6 @@ const App = () => { }); } } else if (authorizationCode) { - // Fallback to the original behavior if no state was restored updateAuthState({ authorizationCode, oauthStep: "token_request", @@ -349,7 +391,6 @@ const App = () => { [sseUrl], ); - // Load OAuth tokens when sseUrl changes useEffect(() => { const loadOAuthTokens = async () => { try { @@ -408,6 +449,18 @@ const App = () => { } }, []); + useEffect(() => { + const handleHashChange = () => { + const hash = window.location.hash.slice(1); + if (hash && hash !== activeTab) { + setActiveTab(hash); + } + }; + + window.addEventListener("hashchange", handleHashChange); + return () => window.removeEventListener("hashchange", handleHashChange); + }, [activeTab]); + const handleApproveSampling = (id: number, result: CreateMessageResult) => { setPendingSampleRequests((prev) => { const request = prev.find((r) => r.id === id); @@ -430,7 +483,34 @@ const App = () => { ) => { setPendingElicitationRequests((prev) => { const request = prev.find((r) => r.id === id); - request?.resolve(response); + if (request) { + request.resolve(response); + + if (request.originatingTab) { + const originatingTab = request.originatingTab; + + const validTabs = [ + ...(serverCapabilities?.resources ? ["resources"] : []), + ...(serverCapabilities?.prompts ? ["prompts"] : []), + ...(serverCapabilities?.tools ? ["tools"] : []), + "ping", + "sampling", + "elicitations", + "roots", + "auth", + ]; + + if (validTabs.includes(originatingTab)) { + setActiveTab(originatingTab); + window.location.hash = originatingTab; + + setTimeout(() => { + setActiveTab(originatingTab); + window.location.hash = originatingTab; + }, 100); + } + } + } return prev.filter((r) => r.id !== id); }); }; @@ -492,7 +572,23 @@ const App = () => { setNextResourceTemplateCursor(response.nextCursor); }; + const getPrompt = async (name: string, args: Record = {}) => { + lastToolCallOriginTabRef.current = currentTabRef.current; + + const response = await sendMCPRequest( + { + method: "prompts/get" as const, + params: { name, arguments: args }, + }, + GetPromptResultSchema, + "prompts", + ); + setPromptContent(JSON.stringify(response, null, 2)); + }; + const readResource = async (uri: string) => { + lastToolCallOriginTabRef.current = currentTabRef.current; + const response = await sendMCPRequest( { method: "resources/read" as const, @@ -549,18 +645,6 @@ const App = () => { setNextPromptCursor(response.nextCursor); }; - const getPrompt = async (name: string, args: Record = {}) => { - const response = await sendMCPRequest( - { - method: "prompts/get" as const, - params: { name, arguments: args }, - }, - GetPromptResultSchema, - "prompts", - ); - setPromptContent(JSON.stringify(response, null, 2)); - }; - const listTools = async () => { const response = await sendMCPRequest( { @@ -572,11 +656,12 @@ const App = () => { ); setTools(response.tools); setNextToolCursor(response.nextCursor); - // Cache output schemas for validation cacheToolOutputSchemas(response.tools); }; const callTool = async (name: string, params: Record) => { + lastToolCallOriginTabRef.current = currentTabRef.current; + try { const response = await sendMCPRequest( { @@ -592,6 +677,7 @@ const App = () => { CompatibilityCallToolResultSchema, "tools", ); + setToolResult(response); } catch (e) { const toolResult: CompatibilityCallToolResult = { @@ -626,7 +712,6 @@ const App = () => { setStdErrNotifications([]); }; - // Helper component for rendering the AuthDebugger const AuthDebuggerWrapper = () => ( { ); - // Helper function to render OAuth callback components if (window.location.pathname === "/oauth/callback") { const OAuthCallback = React.lazy( () => import("./components/OAuthCallback"), @@ -698,7 +782,6 @@ const App = () => { loggingSupported={!!serverCapabilities?.logging || false} clearStdErrNotifications={clearStdErrNotifications} /> - {/* Drag handle for resizing sidebar */}
{
{mcpClient ? ( (window.location.hash = value)} + onValueChange={(value) => { + setActiveTab(value); + window.location.hash = value; + }} > { clearTools={() => { setTools([]); setNextToolCursor(undefined); - // Clear cached output schemas cacheToolOutputSchemas([]); }} callTool={async (name, params) => { diff --git a/client/src/components/ElicitationTab.tsx b/client/src/components/ElicitationTab.tsx index c8c88fa8..cf3be2b5 100644 --- a/client/src/components/ElicitationTab.tsx +++ b/client/src/components/ElicitationTab.tsx @@ -17,6 +17,7 @@ export interface ElicitationResponse { export type PendingElicitationRequest = { id: number; request: ElicitationRequestData; + originatingTab?: string; }; export type Props = { From 1c9b47a0e8faa548fa685d56a0e0bddfcd30a9ba Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Mon, 21 Jul 2025 11:24:23 +0800 Subject: [PATCH 26/34] refactor: ui improvement --- client/src/components/Sidebar.tsx | 182 +++++++++++++++--------------- 1 file changed, 89 insertions(+), 93 deletions(-) diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index f4797098..a41e4bfc 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -103,10 +103,9 @@ const Sidebar = ({ }: SidebarProps) => { const [theme, setTheme] = useTheme(); const [showEnvVars, setShowEnvVars] = useState(false); - const [showBearerToken, setShowBearerToken] = useState(false); + const [showAuthConfig, setShowAuthConfig] = useState(false); const [showConfig, setShowConfig] = useState(false); const [shownEnvVars, setShownEnvVars] = useState>(new Set()); - const [showOauthConfig, setShowOauthConfig] = useState(false); const [copiedServerEntry, setCopiedServerEntry] = useState(false); const [copiedServerFile, setCopiedServerFile] = useState(false); const { toast } = useToast(); @@ -317,97 +316,6 @@ const Sidebar = ({ /> )}
-
- - {showBearerToken && ( -
- - - setHeaderName && setHeaderName(e.target.value) - } - data-testid="header-input" - className="font-mono" - value={headerName} - /> - - setBearerToken(e.target.value)} - data-testid="bearer-token-input" - className="font-mono" - type="password" - /> -
- )} -
- {/* OAuth Configuration */} -
- - {showOauthConfig && ( -
- - setOauthClientId(e.target.value)} - value={oauthClientId} - data-testid="oauth-client-id-input" - className="font-mono" - /> - - - - setOauthScope(e.target.value)} - value={oauthScope} - data-testid="oauth-scope-input" - className="font-mono" - /> -
- )} -
)} @@ -576,6 +484,94 @@ const Sidebar = ({
+
+ + {showAuthConfig && ( + <> + {/* Bearer Token Section */} +
+

+ API Token Authentication +

+
+ + + setHeaderName && setHeaderName(e.target.value) + } + data-testid="header-input" + className="font-mono" + value={headerName} + /> + + setBearerToken(e.target.value)} + data-testid="bearer-token-input" + className="font-mono" + type="password" + /> +
+
+ {transportType !== "stdio" && ( + // OAuth Configuration +
+

+ OAuth 2.0 Flow +

+
+ + setOauthClientId(e.target.value)} + value={oauthClientId} + data-testid="oauth-client-id-input" + className="font-mono" + /> + + + + setOauthScope(e.target.value)} + value={oauthScope} + data-testid="oauth-scope-input" + className="font-mono" + /> +
+
+ )} + + )} +
{/* Configuration */}