From 4e6c7f9878da31cc66990a2a441b7b03da8d6f00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Barthelet?= Date: Sun, 25 May 2025 16:29:00 -0700 Subject: [PATCH 01/51] Set completion support to false by default --- client/src/lib/hooks/useConnection.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 9c825318..5ef407a0 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -86,7 +86,7 @@ export function useConnection({ const [requestHistory, setRequestHistory] = useState< { request: string; response?: string }[] >([]); - const [completionsSupported, setCompletionsSupported] = useState(true); + const [completionsSupported, setCompletionsSupported] = useState(false); const pushHistory = (request: object, response?: object) => { setRequestHistory((prev) => [ @@ -443,7 +443,7 @@ export function useConnection({ throw error; } setServerCapabilities(capabilities ?? null); - setCompletionsSupported(true); // Reset completions support on new connection + setCompletionsSupported(capabilities?.completions !== undefined); if (onPendingRequest) { client.setRequestHandler(CreateMessageRequestSchema, (request) => { From c4b3b3f7639a2ca1b60790c0bc55fe0610532d2b Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Mon, 16 Jun 2025 18:48:22 -0600 Subject: [PATCH 02/51] make auth token configurable via env var --- server/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/index.ts b/server/src/index.ts index 38d62b71..67bac919 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -89,7 +89,7 @@ app.use((req, res, next) => { const webAppTransports: Map = new Map(); // Web app transports by web app sessionId const serverTransports: Map = new Map(); // Server Transports by web app sessionId -const sessionToken = randomBytes(32).toString("hex"); +const sessionToken = process.env.MCP_PROXY_AUTH_TOKEN || randomBytes(32).toString("hex"); const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH; // Origin validation middleware to prevent DNS rebinding attacks From 0f3d34bc9a161e65457fdfd55f50e56ff6ced814 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Mon, 16 Jun 2025 18:58:51 -0600 Subject: [PATCH 03/51] docs: add note in readme --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index ae82a695..dd1b79ee 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,12 @@ If you need to disable authentication (NOT RECOMMENDED), you can set the `DANGER DANGEROUSLY_OMIT_AUTH=true npm start ``` +You can also set the token via the `MCP_PROXY_AUTH_TOKEN` environment variable when starting the server: + +```bash +MCP_PROXY_AUTH_TOKEN=$(openssl rand -hex 32) npm start +``` + #### Local-only Binding By default, the MCP Inspector proxy server binds only to `127.0.0.1` (localhost) to prevent network access. This ensures the server is not accessible from other devices on the network. If you need to bind to all interfaces for development purposes, you can override this with the `HOST` environment variable: From e163aea0e078790ee46cbf4f7a4603bc627cea58 Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Thu, 19 Jun 2025 15:03:39 +0200 Subject: [PATCH 04/51] 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 05/51] 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 06/51] 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 07/51] 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 08/51] 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 09/51] 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 10/51] 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 11/51] 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 12/51] 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 13/51] 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 14/51] 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 15/51] 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 16/51] 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 17/51] 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 18/51] 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 19/51] 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 28/51] 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 7c2b7617487a35d4fa76d420a7d81629a7ad1104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Leblanc?= Date: Wed, 9 Jul 2025 11:37:53 -0400 Subject: [PATCH 29/51] [cli] Don't force specific endpoints for remote servers At the moment, the CLI tool expects that remote MCP servers, either using SSE or streamable HTTP transport, have certain endpoints or fragments -- `/sse` and `/mcp`, respectively. This is not part of the MCP spec, nor should it be expected. I also added a check to make sure a URL is provided if STDIO isn't picked, erroring out if not. --- cli/scripts/cli-tests.js | 20 ++++++++++++++++++++ cli/src/transport.ts | 28 ++++++++-------------------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js index 51bda553..0585b6ea 100755 --- a/cli/scripts/cli-tests.js +++ b/cli/scripts/cli-tests.js @@ -710,6 +710,26 @@ async function runTests() { "tools/list", ); + // Test 29: HTTP transport without URL (should fail) + await runErrorTest( + "http_transport_without_url", + "--transport", + "http", + "--cli", + "--method", + "tools/list", + ); + + // Test 30: SSE transport without URL (should fail) + await runErrorTest( + "sse_transport_without_url", + "--transport", + "sse", + "--cli", + "--method", + "tools/list", + ); + // Kill HTTP server try { process.kill(-httpServer.pid); diff --git a/cli/src/transport.ts b/cli/src/transport.ts index e0d67b4e..b6276356 100644 --- a/cli/src/transport.ts +++ b/cli/src/transport.ts @@ -14,24 +14,6 @@ export type TransportOptions = { url?: string; }; -function createSSETransport(options: TransportOptions): Transport { - const baseUrl = new URL(options.url ?? ""); - const sseUrl = baseUrl.pathname.endsWith("/sse") - ? baseUrl - : new URL("/sse", baseUrl); - - return new SSEClientTransport(sseUrl); -} - -function createHTTPTransport(options: TransportOptions): Transport { - const baseUrl = new URL(options.url ?? ""); - const mcpUrl = baseUrl.pathname.endsWith("/mcp") - ? baseUrl - : new URL("/mcp", baseUrl); - - return new StreamableHTTPClientTransport(mcpUrl); -} - function createStdioTransport(options: TransportOptions): Transport { let args: string[] = []; @@ -75,12 +57,18 @@ export function createTransport(options: TransportOptions): Transport { return createStdioTransport(options); } + // If not STDIO, then it must be either SSE or HTTP. + if (!options.url) { + throw new Error("URL must be provided for SSE or HTTP transport types."); + } + const url = new URL(options.url); + if (transportType === "sse") { - return createSSETransport(options); + return new SSEClientTransport(url); } if (transportType === "http") { - return createHTTPTransport(options); + return new StreamableHTTPClientTransport(url); } throw new Error(`Unsupported transport type: ${transportType}`); From 7d888fcfa3b7fa257d3066bf5f94f8ab24b577fd Mon Sep 17 00:00:00 2001 From: Laziz Turakulov <4857092+LazaUK@users.noreply.github.com> Date: Thu, 10 Jul 2025 00:05:21 +0100 Subject: [PATCH 30/51] Missing mandatory parameter in the Inspector's CLI command Attempts to execute this command will fail: npx @modelcontextprotocol/inspector --cli http://127.0.0.1:8000 --transport http. The error indicates that --method is a mandatory parameter: "Method is required. Use --method to specify the method to invoke. Failed with exit code: 1". That's why the right command may look like this: npx @modelcontextprotocol/inspector --cli http://127.0.0.1:8000 --transport http --method tools/list. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6368ed04..b02a4a84 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,7 @@ npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/l npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com # Connect to a remote MCP server (with Streamable HTTP transport) -npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http +npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list # Call a tool on a remote server npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method tools/call --tool-name remotetool --tool-arg param=value From 9e8042949d9f003080c60db457ee387471c899bb Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Mon, 14 Jul 2025 17:22:38 +0800 Subject: [PATCH 31/51] 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: Mon, 14 Jul 2025 13:45:50 -0400 Subject: [PATCH 32/51] Fix a failing test --- cli/scripts/cli-tests.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js index 0585b6ea..0c5a6f2d 100755 --- a/cli/scripts/cli-tests.js +++ b/cli/scripts/cli-tests.js @@ -680,7 +680,7 @@ async function runTests() { // Test 26: HTTP transport with explicit --transport http flag await runBasicTest( "http_transport_with_explicit_flag", - "http://127.0.0.1:3001", + "http://127.0.0.1:3001/mcp", "--transport", "http", "--cli", From b3cb84473004f48922e95487299c3010a7f519ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Leblanc?= Date: Mon, 14 Jul 2025 14:24:05 -0400 Subject: [PATCH 33/51] Run the CLI tests in CI Right now, the inspector has a CLI feature as well as a series of tests. However, they're not currently run in CI. For the moment, they are only triggered on file changes in the CLI folder, as AFAICT it is currently a separate code base. --- .github/workflows/cli_tests.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/cli_tests.yml diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml new file mode 100644 index 00000000..d61e5a4a --- /dev/null +++ b/.github/workflows/cli_tests.yml @@ -0,0 +1,26 @@ +name: CLI Tests + +on: + push: + paths: + - 'cli/**' + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./cli + steps: + - uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: Install dependencies + run: npm install + + - name: Run tests + run: npm test From 1842063ce701e0156cdbbf637d1bd58d89ecdb37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Leblanc?= Date: Mon, 14 Jul 2025 14:29:04 -0400 Subject: [PATCH 34/51] Remove unused import This commit is mostly so that we can see whether the CI triggers a check of the CLI tests when files are touched within the CLI folder. --- cli/scripts/cli-tests.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js index 51bda553..50cf03c9 100755 --- a/cli/scripts/cli-tests.js +++ b/cli/scripts/cli-tests.js @@ -12,7 +12,7 @@ const colors = { import fs from "fs"; import path from "path"; -import { execSync, spawn } from "child_process"; +import { spawn } from "child_process"; import os from "os"; import { fileURLToPath } from "url"; From f49f97f585fa082e6ed2323b3b5f7c5665fdda01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Leblanc?= Date: Mon, 14 Jul 2025 22:06:20 -0400 Subject: [PATCH 35/51] Run prettier --- .github/workflows/cli_tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml index d61e5a4a..4e7be19c 100644 --- a/.github/workflows/cli_tests.yml +++ b/.github/workflows/cli_tests.yml @@ -3,7 +3,7 @@ name: CLI Tests on: push: paths: - - 'cli/**' + - "cli/**" jobs: test: @@ -17,7 +17,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v2 with: - node-version: '14' + node-version: "14" - name: Install dependencies run: npm install From ea6177b552063c371ef81e9f322117d06657bb32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Leblanc?= Date: Mon, 14 Jul 2025 22:11:37 -0400 Subject: [PATCH 36/51] Run in both pull requests and push events --- .github/workflows/cli_tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml index 4e7be19c..c70823be 100644 --- a/.github/workflows/cli_tests.yml +++ b/.github/workflows/cli_tests.yml @@ -4,6 +4,9 @@ on: push: paths: - "cli/**" + pull_request: + paths: + - "cli/**" jobs: test: From ace91a81ad71b599238f66d04fc9cb0cfddf278c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Leblanc?= Date: Mon, 14 Jul 2025 22:25:47 -0400 Subject: [PATCH 37/51] Use Node version defined in cli/package.json --- .github/workflows/cli_tests.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml index c70823be..eb18a2c6 100644 --- a/.github/workflows/cli_tests.yml +++ b/.github/workflows/cli_tests.yml @@ -15,15 +15,19 @@ jobs: run: working-directory: ./cli steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: - node-version: "14" + node-version-file: package.json + cache: npm - - name: Install dependencies - run: npm install + # Working around https://github.com/npm/cli/issues/4828 + - run: npm install --no-package-lock + + - name: Build CLI + run: npm run build - name: Run tests run: npm test From 718e61d492b071e1be656465aca3ed22162e3c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Leblanc?= Date: Mon, 14 Jul 2025 22:36:00 -0400 Subject: [PATCH 38/51] Use simpler install method? --- .github/workflows/cli_tests.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml index eb18a2c6..51b64c1c 100644 --- a/.github/workflows/cli_tests.yml +++ b/.github/workflows/cli_tests.yml @@ -23,8 +23,7 @@ jobs: node-version-file: package.json cache: npm - # Working around https://github.com/npm/cli/issues/4828 - - run: npm install --no-package-lock + - run: npm install - name: Build CLI run: npm run build From 9adcc0dd7739ff95146423e7e44aa4e543abd26c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Leblanc?= Date: Mon, 14 Jul 2025 23:10:56 -0400 Subject: [PATCH 39/51] Add logging for pwd --- .github/workflows/cli_tests.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml index 51b64c1c..678941fc 100644 --- a/.github/workflows/cli_tests.yml +++ b/.github/workflows/cli_tests.yml @@ -23,7 +23,11 @@ jobs: node-version-file: package.json cache: npm - - run: npm install + - name: Current directory + run: pwd + + - name: Install dependencies + run: npm install - name: Build CLI run: npm run build From f10b76b0ae9c65319d03c4d8ef216c60d0d3907d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Leblanc?= Date: Mon, 14 Jul 2025 23:17:06 -0400 Subject: [PATCH 40/51] Run commands from root dir instead --- .github/workflows/cli_tests.yml | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml index 678941fc..a393c79a 100644 --- a/.github/workflows/cli_tests.yml +++ b/.github/workflows/cli_tests.yml @@ -11,26 +11,20 @@ on: jobs: test: runs-on: ubuntu-latest - defaults: - run: - working-directory: ./cli steps: - uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version-file: package.json + node-version-file: cli/package.json cache: npm - - name: Current directory - run: pwd - - name: Install dependencies - run: npm install + run: npm install --workspace=cli - name: Build CLI - run: npm run build + run: npm run build --workspace=cli - name: Run tests - run: npm test + run: npm run test --workspace=cli From c1ab2a52d28de558170d56036b2f60bc9be61a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Leblanc?= Date: Mon, 14 Jul 2025 23:19:05 -0400 Subject: [PATCH 41/51] Stay in cli/ but ignore scripts? --- .github/workflows/cli_tests.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml index a393c79a..4a975b38 100644 --- a/.github/workflows/cli_tests.yml +++ b/.github/workflows/cli_tests.yml @@ -11,6 +11,9 @@ on: jobs: test: runs-on: ubuntu-latest + defaults: + run: + working-directory: ./cli steps: - uses: actions/checkout@v4 @@ -21,10 +24,10 @@ jobs: cache: npm - name: Install dependencies - run: npm install --workspace=cli + run: npm install --ignore-scripts - name: Build CLI - run: npm run build --workspace=cli + run: npm run build - name: Run tests - run: npm run test --workspace=cli + run: npm test From b6a4aa40cbe02a522eb5af7a18fd1c22cfb47dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Leblanc?= Date: Mon, 14 Jul 2025 23:21:15 -0400 Subject: [PATCH 42/51] Install more, but stay put --- .github/workflows/cli_tests.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml index 4a975b38..46431b46 100644 --- a/.github/workflows/cli_tests.yml +++ b/.github/workflows/cli_tests.yml @@ -24,7 +24,9 @@ jobs: cache: npm - name: Install dependencies - run: npm install --ignore-scripts + run: | + cd .. + npm ci --ignore-scripts - name: Build CLI run: npm run build From 616ef0d04c62591d47a75b81339ab08a1be8da20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Leblanc?= Date: Mon, 14 Jul 2025 23:22:47 -0400 Subject: [PATCH 43/51] Use the node version in root package.json --- .github/workflows/cli_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml index 46431b46..3ea0f0e7 100644 --- a/.github/workflows/cli_tests.yml +++ b/.github/workflows/cli_tests.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version-file: cli/package.json + node-version-file: package.json cache: npm - name: Install dependencies From ff2d932904b8dd2ef5d5f8cd18b25440db5fe472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Leblanc?= Date: Mon, 14 Jul 2025 23:27:58 -0400 Subject: [PATCH 44/51] Add await to runCli call --- .github/workflows/cli_tests.yml | 3 +++ cli/src/cli.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml index 3ea0f0e7..6f199518 100644 --- a/.github/workflows/cli_tests.yml +++ b/.github/workflows/cli_tests.yml @@ -31,5 +31,8 @@ jobs: - name: Build CLI run: npm run build + - name: Explicitly pre-install test dependencies + run: npx -y @modelcontextprotocol/server-everything --help || true + - name: Run tests run: npm test diff --git a/cli/src/cli.ts b/cli/src/cli.ts index de0153b0..f29743ab 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -233,7 +233,7 @@ async function main(): Promise { const args = parseArgs(); if (args.cli) { - runCli(args); + await runCli(args); } else { await runWebClient(args); } From b84e5271f49a623de377f9b0b116d7e8ec9114cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Leblanc?= Date: Mon, 14 Jul 2025 23:35:52 -0400 Subject: [PATCH 45/51] Explicitly exit process for CI --- .github/workflows/cli_tests.yml | 3 +++ cli/src/index.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml index 6f199518..8bd3bb8e 100644 --- a/.github/workflows/cli_tests.yml +++ b/.github/workflows/cli_tests.yml @@ -36,3 +36,6 @@ jobs: - name: Run tests run: npm test + env: + NPM_CONFIG_YES: true + CI: true diff --git a/cli/src/index.ts b/cli/src/index.ts index 5d5dcf8b..2b0c4f53 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -287,6 +287,8 @@ async function main(): Promise { try { const args = parseArgs(); await callMethod(args); + // Explicitly exit to ensure process terminates in CI + process.exit(0); } catch (error) { handleError(error); } From 6b204b5d29aaedfbec3ad84a15a34bd428b04e09 Mon Sep 17 00:00:00 2001 From: Alex Andru Date: Fri, 18 Jul 2025 18:21:40 +0200 Subject: [PATCH 46/51] 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 47/51] 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 */}