From d65ed01922b64afff5790460046d2ef09c238563 Mon Sep 17 00:00:00 2001 From: Nandha Reddy Date: Thu, 19 Jun 2025 02:32:40 +1000 Subject: [PATCH 01/40] Add JSON validation to tool execution Prevents execution of tools with invalid JSON parameters by validating JSON syntax before tool execution and showing error UI for invalid input. - Add validateJson method to DynamicJsonForm via forwardRef - Add validation check in ToolsTab before tool execution - Reuse existing JsonEditor error display for consistent UX --- client/src/components/DynamicJsonForm.tsx | 31 ++++++++++++++++++++--- client/src/components/ToolsTab.tsx | 26 +++++++++++++++---- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 6a5993c3..fe2cfd2d 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import JsonEditor from "./JsonEditor"; @@ -13,6 +13,10 @@ interface DynamicJsonFormProps { maxDepth?: number; } +export interface DynamicJsonFormRef { + validateJson: () => { isValid: boolean; error: string | null }; +} + const isSimpleObject = (schema: JsonSchemaType): boolean => { const supportedTypes = ["string", "number", "integer", "boolean", "null"]; if (supportedTypes.includes(schema.type)) return true; @@ -22,12 +26,12 @@ const isSimpleObject = (schema: JsonSchemaType): boolean => { ); }; -const DynamicJsonForm = ({ +const DynamicJsonForm = forwardRef(({ schema, value, onChange, maxDepth = 3, -}: DynamicJsonFormProps) => { +}, ref) => { const isOnlyJSON = !isSimpleObject(schema); const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON); const [jsonError, setJsonError] = useState(); @@ -108,6 +112,25 @@ const DynamicJsonForm = ({ } }; + const validateJson = () => { + if (!isJsonMode) return { isValid: true, error: null }; + try { + const jsonStr = rawJsonValue.trim(); + if (!jsonStr) return { isValid: true, error: null }; + JSON.parse(jsonStr); + setJsonError(undefined); + return { isValid: true, error: null }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Invalid JSON"; + setJsonError(errorMessage); + return { isValid: false, error: errorMessage }; + } + }; + + useImperativeHandle(ref, () => ({ + validateJson, + })); + const renderFormFields = ( propSchema: JsonSchemaType, currentValue: JsonValue, @@ -303,6 +326,6 @@ const DynamicJsonForm = ({ )} ); -}; +}); export default DynamicJsonForm; diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 8a7f6578..1ce034fb 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -1,11 +1,11 @@ -import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { TabsContent } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; -import DynamicJsonForm from "./DynamicJsonForm"; +import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm"; import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils"; import { generateDefaultValue } from "@/utils/schemaUtils"; import { @@ -13,8 +13,8 @@ import { ListToolsResult, Tool, } from "@modelcontextprotocol/sdk/types.js"; -import { Loader2, Send, ChevronDown, ChevronUp } from "lucide-react"; -import { useEffect, useState } from "react"; +import { Loader2, Send, ChevronDown, ChevronUp, AlertCircle } from "lucide-react"; +import { useEffect, useState, useRef } from "react"; import ListPane from "./ListPane"; import JsonView from "./JsonView"; import ToolResults from "./ToolResults"; @@ -28,6 +28,7 @@ const ToolsTab = ({ setSelectedTool, toolResult, nextCursor, + error, }: { tools: Tool[]; listTools: () => void; @@ -42,6 +43,7 @@ const ToolsTab = ({ const [params, setParams] = useState>({}); const [isToolRunning, setIsToolRunning] = useState(false); const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false); + const formRefs = useRef>({}); useEffect(() => { const params = Object.entries( @@ -84,7 +86,13 @@ const ToolsTab = ({
- {selectedTool ? ( + {error ? ( + + + Error + {error} + + ) : selectedTool ? (

{selectedTool.description} @@ -137,6 +145,7 @@ const ToolsTab = ({ ) : prop.type === "object" || prop.type === "array" ? (

(formRefs.current[key] = ref)} schema={{ type: prop.type, properties: prop.properties, @@ -174,6 +183,7 @@ const ToolsTab = ({ ) : (
(formRefs.current[key] = ref)} schema={{ type: prop.type, properties: prop.properties, @@ -232,6 +242,12 @@ const ToolsTab = ({ )} -
- ))} - -
-
- ); - } - - // For complex arrays, fall back to JSON editor + const renderFormFields = ( + propSchema: JsonSchemaType, + currentValue: JsonValue, + path: string[] = [], + depth: number = 0, + parentSchema?: JsonSchemaType, + propertyName?: string, + ) => { + if ( + depth >= maxDepth && + (propSchema.type === "object" || propSchema.type === "array") + ) { + // Render as JSON editor when max depth is reached return ( { try { const parsed = JSON.parse(newValue); @@ -392,93 +200,296 @@ const DynamicJsonForm = forwardRef(({ /> ); } - default: - return null; - } - }; - const handleFieldChange = (path: string[], fieldValue: JsonValue) => { - if (path.length === 0) { - onChange(fieldValue); - return; - } + // Check if this property is required in the parent schema + const isRequired = + parentSchema?.required?.includes(propertyName || "") ?? false; - try { - const newValue = updateValueAtPath(value, path, fieldValue); - onChange(newValue); - } catch (error) { - console.error("Failed to update form value:", error); - onChange(value); - } - }; + switch (propSchema.type) { + case "string": + return ( + { + const val = e.target.value; + // Always allow setting string values, including empty strings + handleFieldChange(path, val); + }} + placeholder={propSchema.description} + required={isRequired} + /> + ); + case "number": + return ( + { + const val = e.target.value; + if (!val && !isRequired) { + handleFieldChange(path, undefined); + } else { + const num = Number(val); + if (!isNaN(num)) { + handleFieldChange(path, num); + } + } + }} + placeholder={propSchema.description} + required={isRequired} + /> + ); + case "integer": + return ( + { + const val = e.target.value; + if (!val && !isRequired) { + 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={isRequired} + /> + ); + case "boolean": + return ( + handleFieldChange(path, e.target.checked)} + className="w-4 h-4" + required={isRequired} + /> + ); + case "object": + if (!propSchema.properties) { + return ( + { + try { + const parsed = JSON.parse(newValue); + handleFieldChange(path, parsed); + setJsonError(undefined); + } catch (err) { + setJsonError( + err instanceof Error ? err.message : "Invalid JSON", + ); + } + }} + error={jsonError} + /> + ); + } - const shouldUseJsonMode = - schema.type === "object" && - (!schema.properties || Object.keys(schema.properties).length === 0); + return ( +
+ {Object.entries(propSchema.properties).map(([key, subSchema]) => ( +
+ + {renderFormFields( + subSchema as JsonSchemaType, + (currentValue as Record)?.[key], + [...path, key], + depth + 1, + propSchema, + key, + )} +
+ ))} +
+ ); + case "array": { + const arrayValue = Array.isArray(currentValue) ? currentValue : []; + if (!propSchema.items) return null; - useEffect(() => { - if (shouldUseJsonMode && !isJsonMode) { - setIsJsonMode(true); - } - }, [shouldUseJsonMode, isJsonMode]); + // If the array items are simple, render as form fields, otherwise use JSON editor + if (isSimpleObject(propSchema.items)) { + return ( +
+ {propSchema.description && ( +

+ {propSchema.description} +

+ )} - return ( -
-
- {isJsonMode && ( - - )} - {!isOnlyJSON && ( - + {propSchema.items?.description && ( +

+ Items: {propSchema.items.description} +

+ )} + +
+ {arrayValue.map((item, index) => ( +
+ {renderFormFields( + propSchema.items as JsonSchemaType, + item, + [...path, index.toString()], + depth + 1, + )} + +
+ ))} + +
+
+ ); + } + + // For complex arrays, fall back to JSON editor + return ( + { + try { + const parsed = JSON.parse(newValue); + handleFieldChange(path, parsed); + setJsonError(undefined); + } catch (err) { + setJsonError( + err instanceof Error ? err.message : "Invalid JSON", + ); + } + }} + error={jsonError} + /> + ); + } + default: + return null; + } + }; + + const handleFieldChange = (path: string[], fieldValue: JsonValue) => { + if (path.length === 0) { + onChange(fieldValue); + return; + } + + try { + const newValue = updateValueAtPath(value, path, fieldValue); + onChange(newValue); + } catch (error) { + console.error("Failed to update form value:", error); + onChange(value); + } + }; + + const shouldUseJsonMode = + schema.type === "object" && + (!schema.properties || Object.keys(schema.properties).length === 0); + + useEffect(() => { + if (shouldUseJsonMode && !isJsonMode) { + setIsJsonMode(true); + } + }, [shouldUseJsonMode, isJsonMode]); + + return ( +
+
+ {isJsonMode && ( + + )} + {!isOnlyJSON && ( + + )} +
+ + {isJsonMode ? ( + { + // Always update local state + setRawJsonValue(newValue); + + // Use the debounced function to attempt parsing and updating parent + debouncedUpdateParent(newValue); + }} + error={jsonError} + /> + ) : // If schema type is object but value is not an object or is empty, and we have actual JSON data, + // render a simple representation of the JSON data + schema.type === "object" && + (typeof value !== "object" || + value === null || + Object.keys(value).length === 0) && + rawJsonValue && + rawJsonValue !== "{}" ? ( +
+

+ Form view not available for this JSON structure. Using simplified + view: +

+
+              {rawJsonValue}
+            
+

+ Use JSON mode for full editing capabilities. +

+
+ ) : ( + renderFormFields(schema, value) )}
- - {isJsonMode ? ( - { - // Always update local state - setRawJsonValue(newValue); - - // Use the debounced function to attempt parsing and updating parent - debouncedUpdateParent(newValue); - }} - error={jsonError} - /> - ) : // If schema type is object but value is not an object or is empty, and we have actual JSON data, - // render a simple representation of the JSON data - schema.type === "object" && - (typeof value !== "object" || - value === null || - Object.keys(value).length === 0) && - rawJsonValue && - rawJsonValue !== "{}" ? ( -
-

- Form view not available for this JSON structure. Using simplified - view: -

-
-            {rawJsonValue}
-          
-

- Use JSON mode for full editing capabilities. -

-
- ) : ( - renderFormFields(schema, value) - )} -
- ); -}); + ); + }, +); export default DynamicJsonForm; diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 9f0d2008..7258eaa8 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -13,7 +13,13 @@ import { ListToolsResult, Tool, } from "@modelcontextprotocol/sdk/types.js"; -import { Loader2, Send, ChevronDown, ChevronUp, AlertCircle } from "lucide-react"; +import { + Loader2, + Send, + ChevronDown, + ChevronUp, + AlertCircle, +} from "lucide-react"; import { useEffect, useState, useRef } from "react"; import ListPane from "./ListPane"; import JsonView from "./JsonView"; @@ -254,9 +260,9 @@ const ToolsTab = ({ + <> + + + )} {!isOnlyJSON && ( +
+ ); + }; + + return render(); + }; + + describe("validateJson method", () => { + it("should return valid for form mode", () => { + const simpleSchema = { + type: "string" as const, + description: "Test string field", + }; + + const TestComponent = () => { + const formRef = useRef(null); + + return ( +
+ + +
+ ); + }; + + render(); + + const validateButton = screen.getByTestId("validate-button"); + fireEvent.click(validateButton); + + expect(validateButton.getAttribute('data-validation-valid')).toBe('true'); + expect(validateButton.getAttribute('data-validation-error')).toBe(''); + }); + + it("should return valid for valid JSON in JSON mode", () => { + renderFormWithRef(); + + const validateButton = screen.getByTestId("validate-button"); + fireEvent.click(validateButton); + + expect(validateButton.getAttribute('data-validation-valid')).toBe('true'); + expect(validateButton.getAttribute('data-validation-error')).toBe(''); + }); + + it("should return invalid for malformed JSON in JSON mode", async () => { + renderFormWithRef(); + + // Enter invalid JSON + const textarea = screen.getByRole("textbox"); + fireEvent.change(textarea, { target: { value: '{ "invalid": json }' } }); + + // Wait a bit for any debounced updates + await waitFor(() => { + const validateButton = screen.getByTestId("validate-button"); + fireEvent.click(validateButton); + + expect(validateButton.getAttribute('data-validation-valid')).toBe('false'); + expect(validateButton.getAttribute('data-validation-error')).toContain('JSON'); + }); + }); + + it("should return valid for empty JSON in JSON mode", () => { + renderFormWithRef(); + + // Clear the textarea + const textarea = screen.getByRole("textbox"); + fireEvent.change(textarea, { target: { value: '' } }); + + const validateButton = screen.getByTestId("validate-button"); + fireEvent.click(validateButton); + + expect(validateButton.getAttribute('data-validation-valid')).toBe('true'); + expect(validateButton.getAttribute('data-validation-error')).toBe(''); + }); + + it("should set error state when validation fails", async () => { + renderFormWithRef(); + + // Enter invalid JSON + const textarea = screen.getByRole("textbox"); + fireEvent.change(textarea, { target: { value: '{ "trailing": "comma", }' } }); + + // Trigger validation + const validateButton = screen.getByTestId("validate-button"); + fireEvent.click(validateButton); + + // Check that validation result shows error + expect(validateButton.getAttribute('data-validation-valid')).toBe('false'); + expect(validateButton.getAttribute('data-validation-error')).toContain('JSON'); + }); + }); + + describe("forwardRef functionality", () => { + it("should expose validateJson method through ref", () => { + const TestComponent = () => { + const formRef = useRef(null); + + return ( +
+ + +
+ ); + }; + + render(); + + const testButton = screen.getByTestId("ref-test-button"); + fireEvent.click(testButton); + + expect(testButton.getAttribute('data-has-validate-method')).toBe('true'); + }); + }); +}); diff --git a/client/src/components/__tests__/ToolsTab.test.tsx b/client/src/components/__tests__/ToolsTab.test.tsx index bc75401d..a9e7b5fb 100644 --- a/client/src/components/__tests__/ToolsTab.test.tsx +++ b/client/src/components/__tests__/ToolsTab.test.tsx @@ -637,4 +637,171 @@ describe("ToolsTab", () => { expect(screen.getByText(/version/i)).toBeInTheDocument(); }); }); + + describe("JSON Validation Integration", () => { + const toolWithJsonParams: Tool = { + name: "jsonTool", + description: "Tool with JSON parameters", + inputSchema: { + type: "object" as const, + properties: { + config: { + type: "object" as const, + // No properties defined - this will force JSON mode + }, + data: { + type: "array" as const, + // No items defined - this will force JSON mode + }, + }, + }, + }; + + it("should prevent tool execution when JSON validation fails", async () => { + const mockCallTool = jest.fn(); + renderToolsTab({ + tools: [toolWithJsonParams], + selectedTool: toolWithJsonParams, + callTool: mockCallTool, + }); + + // Find JSON editor textareas (there should be at least 1 for JSON parameters) + const textareas = screen.getAllByRole("textbox"); + expect(textareas.length).toBeGreaterThanOrEqual(1); + + // Enter invalid JSON in the first textarea + const configTextarea = textareas[0]; + fireEvent.change(configTextarea, { + target: { value: '{ "invalid": json }' } + }); + + // Try to run the tool + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + + // Tool should not have been called due to validation failure + expect(mockCallTool).not.toHaveBeenCalled(); + }); + + it("should allow tool execution when JSON validation passes", async () => { + const mockCallTool = jest.fn(); + renderToolsTab({ + tools: [toolWithJsonParams], + selectedTool: toolWithJsonParams, + callTool: mockCallTool, + }); + + // Find JSON editor textareas + const textareas = screen.getAllByRole("textbox"); + + // Enter valid JSON in the first textarea + fireEvent.change(textareas[0], { + target: { value: '{ "config": { "setting": "value" }, "data": ["item1", "item2"] }' } + }); + + // Wait for debounced updates + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 350)); + }); + + // Try to run the tool + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + + // Tool should have been called successfully + expect(mockCallTool).toHaveBeenCalled(); + }); + + it("should handle mixed valid and invalid JSON parameters", async () => { + const mockCallTool = jest.fn(); + renderToolsTab({ + tools: [toolWithJsonParams], + selectedTool: toolWithJsonParams, + callTool: mockCallTool, + }); + + const textareas = screen.getAllByRole("textbox"); + + // Enter invalid JSON that contains both valid and invalid parts + fireEvent.change(textareas[0], { + target: { value: '{ "config": { "setting": "value" }, "data": ["unclosed array" }' } + }); + + // Try to run the tool + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + + // Tool should not have been called due to validation failure + expect(mockCallTool).not.toHaveBeenCalled(); + }); + + it("should work with tools that have no JSON parameters", async () => { + const mockCallTool = jest.fn(); + const simpleToolWithStringParam: Tool = { + name: "simpleTool", + description: "Tool with simple parameters", + inputSchema: { + type: "object" as const, + properties: { + message: { type: "string" as const }, + count: { type: "number" as const }, + }, + }, + }; + + renderToolsTab({ + tools: [simpleToolWithStringParam], + selectedTool: simpleToolWithStringParam, + callTool: mockCallTool, + }); + + // Fill in the simple parameters + const messageInput = screen.getByRole("textbox"); + const countInput = screen.getByRole("spinbutton"); + + fireEvent.change(messageInput, { target: { value: "test message" } }); + fireEvent.change(countInput, { target: { value: "5" } }); + + // Run the tool + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + + // Tool should have been called successfully (no JSON validation needed) + expect(mockCallTool).toHaveBeenCalledWith(simpleToolWithStringParam.name, { + message: "test message", + count: 5, + }); + }); + + it("should handle empty JSON parameters correctly", async () => { + const mockCallTool = jest.fn(); + renderToolsTab({ + tools: [toolWithJsonParams], + selectedTool: toolWithJsonParams, + callTool: mockCallTool, + }); + + const textareas = screen.getAllByRole("textbox"); + + // Clear the textarea (empty JSON should be valid) + fireEvent.change(textareas[0], { target: { value: '' } }); + + // Try to run the tool + const runButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(runButton); + }); + + // Tool should have been called (empty JSON is considered valid) + expect(mockCallTool).toHaveBeenCalled(); + }); + }); }); From ad10912e12823cb8407722ca948d596816350af9 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 6 Aug 2025 21:08:15 -0700 Subject: [PATCH 05/40] Fix formatting --- .../__tests__/DynamicJsonForm.test.tsx | 80 +++++++++++++------ .../components/__tests__/ToolsTab.test.tsx | 41 ++++++---- 2 files changed, 81 insertions(+), 40 deletions(-) diff --git a/client/src/components/__tests__/DynamicJsonForm.test.tsx b/client/src/components/__tests__/DynamicJsonForm.test.tsx index e1ad2c4f..541da33d 100644 --- a/client/src/components/__tests__/DynamicJsonForm.test.tsx +++ b/client/src/components/__tests__/DynamicJsonForm.test.tsx @@ -676,10 +676,18 @@ describe("DynamicJsonForm Validation Functionality", () => { onClick={() => { const result = formRef.current?.validateJson(); // Add data attributes to make validation result testable - const button = document.querySelector('[data-testid="validate-button"]') as HTMLElement; + const button = document.querySelector( + '[data-testid="validate-button"]', + ) as HTMLElement; if (button && result) { - button.setAttribute('data-validation-valid', result.isValid.toString()); - button.setAttribute('data-validation-error', result.error || ''); + button.setAttribute( + "data-validation-valid", + result.isValid.toString(), + ); + button.setAttribute( + "data-validation-error", + result.error || "", + ); } }} data-testid="validate-button" @@ -702,7 +710,7 @@ describe("DynamicJsonForm Validation Functionality", () => { const TestComponent = () => { const formRef = useRef(null); - + return (
{ - -
- {items.map((item, index) => ( -
setSelectedItem(item)} - > - {renderItem(item)} +}: ListPaneProps) => { + const [searchQuery, setSearchQuery] = useState(""); + const [isSearchExpanded, setIsSearchExpanded] = useState(false); + const searchInputRef = useRef(null); + + const filteredItems = useMemo(() => { + if (!searchQuery.trim()) return items; + + return items.filter((item) => { + const searchableText = JSON.stringify(item).toLowerCase(); + return searchableText.includes(searchQuery.toLowerCase()); + }); + }, [items, searchQuery]); + + const handleSearchClick = () => { + setIsSearchExpanded(true); + setTimeout(() => { + searchInputRef.current?.focus(); + }, 100); + }; + + const handleSearchBlur = () => { + if (!searchQuery.trim()) { + setIsSearchExpanded(false); + } + }; + + return ( +
+
+
+

{title}

+
+ + +
+
+ + setSearchQuery(e.target.value)} + onBlur={handleSearchBlur} + className="pl-10 w-full transition-all duration-300 ease-in-out" + /> +
+
- ))} +
+
+
+ + +
+ {filteredItems.map((item, index) => ( +
setSelectedItem(item)} + > + {renderItem(item)} +
+ ))} + {filteredItems.length === 0 && searchQuery && items.length > 0 && ( +
+ No items found matching "{searchQuery}" +
+ )} +
-
-); + ); +}; export default ListPane; From a733b239a6875a18a2759f8997d0579123c52816 Mon Sep 17 00:00:00 2001 From: Cameron Roberts Date: Sun, 17 Aug 2025 23:13:00 +0100 Subject: [PATCH 07/40] feat: Add UT for the added search functionality to the ListPane component --- .../components/__tests__/ListPane.test.tsx | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 client/src/components/__tests__/ListPane.test.tsx diff --git a/client/src/components/__tests__/ListPane.test.tsx b/client/src/components/__tests__/ListPane.test.tsx new file mode 100644 index 00000000..672bad04 --- /dev/null +++ b/client/src/components/__tests__/ListPane.test.tsx @@ -0,0 +1,205 @@ +import { render, screen, fireEvent, act } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { describe, it, beforeEach, jest } from "@jest/globals"; +import ListPane from "../ListPane"; + +describe("ListPane", () => { + const mockItems = [ + { id: 1, name: "Tool 1", description: "First tool" }, + { id: 2, name: "Tool 2", description: "Second tool" }, + { id: 3, name: "Another Tool", description: "Third tool" }, + ]; + + const defaultProps = { + items: mockItems, + listItems: jest.fn(), + clearItems: jest.fn(), + setSelectedItem: jest.fn(), + renderItem: (item: (typeof mockItems)[0]) =>
{item.name}
, + title: "List tools", + buttonText: "Load Tools", + }; + + const renderListPane = (props = {}) => { + return render(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Rendering", () => { + it("should render with title and button", () => { + renderListPane(); + + expect(screen.getByText("List tools")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Load Tools" }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument(); + }); + + it("should render items when provided", () => { + renderListPane(); + + expect(screen.getByText("Tool 1")).toBeInTheDocument(); + expect(screen.getByText("Tool 2")).toBeInTheDocument(); + expect(screen.getByText("Another Tool")).toBeInTheDocument(); + }); + + it("should render empty state when no items", () => { + renderListPane({ items: [] }); + + expect(screen.queryByText("Tool 1")).not.toBeInTheDocument(); + expect(screen.queryByText("Tool 2")).not.toBeInTheDocument(); + }); + + it("should render custom item content", () => { + const customRenderItem = (item: (typeof mockItems)[0]) => ( +
+ {item.name} + {item.description} +
+ ); + + renderListPane({ renderItem: customRenderItem }); + + expect(screen.getByText("Tool 1")).toBeInTheDocument(); + expect(screen.getByText("First tool")).toBeInTheDocument(); + }); + }); + + describe("Search Functionality", () => { + it("should show search icon initially", () => { + renderListPane(); + + const searchButton = screen.getByRole("button", { name: "" }); + expect(searchButton).toBeInTheDocument(); + expect(searchButton.querySelector("svg")).toBeInTheDocument(); + }); + + it("should expand search input when search icon is clicked", async () => { + renderListPane(); + + const searchButton = screen.getByRole("button", { name: "" }); + await act(async () => { + fireEvent.click(searchButton); + }); + + const searchInput = screen.getByPlaceholderText("Search..."); + expect(searchInput).toBeInTheDocument(); + + // Wait for the setTimeout to complete and focus to be set + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + expect(searchInput).toHaveFocus(); + }); + + it("should filter items based on search query", async () => { + renderListPane(); + + const searchButton = screen.getByRole("button", { name: "" }); + await act(async () => { + fireEvent.click(searchButton); + }); + + const searchInput = screen.getByPlaceholderText("Search..."); + await act(async () => { + fireEvent.change(searchInput, { target: { value: "Tool" } }); + }); + + expect(screen.getByText("Tool 1")).toBeInTheDocument(); + expect(screen.getByText("Tool 2")).toBeInTheDocument(); + expect(screen.getByText("Another Tool")).toBeInTheDocument(); + + await act(async () => { + fireEvent.change(searchInput, { target: { value: "Another" } }); + }); + + expect(screen.queryByText("Tool 1")).not.toBeInTheDocument(); + expect(screen.queryByText("Tool 2")).not.toBeInTheDocument(); + expect(screen.getByText("Another Tool")).toBeInTheDocument(); + }); + + it("should show 'No items found of matching \"NonExistent\"' when search has no results", async () => { + renderListPane(); + + const searchButton = screen.getByRole("button", { name: "" }); + await act(async () => { + fireEvent.click(searchButton); + }); + + const searchInput = screen.getByPlaceholderText("Search..."); + + await act(async () => { + fireEvent.change(searchInput, { target: { value: "NonExistent" } }); + }); + + expect( + screen.getByText('No items found matching "NonExistent"'), + ).toBeInTheDocument(); + expect(screen.queryByText("Tool 1")).not.toBeInTheDocument(); + }); + + it("should collapse search when input is empty and loses focus", async () => { + renderListPane(); + + const searchButton = screen.getByRole("button", { name: "" }); + await act(async () => { + fireEvent.click(searchButton); + }); + + const searchInput = screen.getByPlaceholderText("Search..."); + + await act(async () => { + fireEvent.change(searchInput, { target: { value: "test" } }); + fireEvent.change(searchInput, { target: { value: "" } }); + fireEvent.blur(searchInput); + }); + + // The search input is hidden with CSS but still in the DOM + // We should check that the search button is visible again + const searchButtonAfterCollapse = screen.getByRole("button", { + name: "", + }); + expect(searchButtonAfterCollapse).toBeInTheDocument(); + expect(searchButtonAfterCollapse).not.toHaveClass("opacity-0"); + }); + + it("should keep search expanded when input has content and loses focus", async () => { + renderListPane(); + + const searchButton = screen.getByRole("button", { name: "" }); + await act(async () => { + fireEvent.click(searchButton); + }); + + const searchInput = screen.getByPlaceholderText("Search..."); + await act(async () => { + fireEvent.change(searchInput, { target: { value: "test" } }); + fireEvent.blur(searchInput); + }); + + expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument(); + }); + + it("should search through all item properties (description)", async () => { + renderListPane(); + + const searchButton = screen.getByRole("button", { name: "" }); + await act(async () => { + fireEvent.click(searchButton); + }); + + const searchInput = screen.getByPlaceholderText("Search..."); + await act(async () => { + fireEvent.change(searchInput, { target: { value: "First tool" } }); + }); + + expect(screen.getByText("Tool 1")).toBeInTheDocument(); + expect(screen.queryByText("Tool 2")).not.toBeInTheDocument(); + }); + }); +}); From c25f84e6bbc175cd8d4d875fc6d5ab4299a4bc4d Mon Sep 17 00:00:00 2001 From: Cameron Roberts Date: Sun, 17 Aug 2025 23:46:16 +0100 Subject: [PATCH 08/40] feat: adjust UT to have better test case --- client/src/components/ListPane.tsx | 3 +++ .../src/components/__tests__/ListPane.test.tsx | 16 ++++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/client/src/components/ListPane.tsx b/client/src/components/ListPane.tsx index 296fb753..09c9f57d 100644 --- a/client/src/components/ListPane.tsx +++ b/client/src/components/ListPane.tsx @@ -57,6 +57,8 @@ const ListPane = ({

{title}

- -
-
- - setSearchQuery(e.target.value)} - onBlur={handleSearchBlur} - className="pl-10 w-full transition-all duration-300 ease-in-out" - /> +
+

+ {title} +

+
+ {!isSearchExpanded ? ( + + ) : ( +
+
+ + setSearchQuery(e.target.value)} + onBlur={handleSearchBlur} + className="pl-10 w-full transition-all duration-300 ease-in-out" + /> +
-
+ )}
From 36c38e7b47dbec9b1ce54bd6eeb21b16fd73b33e Mon Sep 17 00:00:00 2001 From: Cameron Roberts <40502796+cameronldroberts@users.noreply.github.com> Date: Sun, 14 Sep 2025 00:29:54 +0100 Subject: [PATCH 18/40] Update ListPane.tsx --- client/src/components/ListPane.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/components/ListPane.tsx b/client/src/components/ListPane.tsx index e746a0b0..8cc2c1fe 100644 --- a/client/src/components/ListPane.tsx +++ b/client/src/components/ListPane.tsx @@ -32,7 +32,6 @@ const ListPane = ({ if (!searchQuery.trim()) return items; return items.filter((item) => { - console.log('item : ', item); const searchableText = [ (item as { name?: string }).name || "", (item as { description?: string }).description || "", From b9cd692b553b0659034069beaff6556fda51a62d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Barthelet?= Date: Fri, 19 Sep 2025 13:53:10 +0200 Subject: [PATCH 19/40] Update link to actual mcp revision introducing protected resource metadata endpoint --- client/src/components/OAuthFlowProgress.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 5f44a4f5..6e0fd695 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -181,8 +181,8 @@ export const OAuthFlowProgress = ({

Resource metadata was added in the{" "} - - 2025-DRAFT-v2 specification update + + 2025-06-18 specification update
{authState.resourceMetadataError.message} From 8d149b267314f786b9176f9e007652ddaf9fe577 Mon Sep 17 00:00:00 2001 From: Nandha Reddy Date: Wed, 24 Sep 2025 00:25:06 +1000 Subject: [PATCH 20/40] Add real-time JSON validation and prevent tool execution on invalid input --- client/src/components/DynamicJsonForm.tsx | 20 ++++++++++++++--- client/src/components/ToolsTab.tsx | 27 ++++++++++++++++++----- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 84c51117..e2d7d7ed 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -106,8 +106,17 @@ const DynamicJsonForm = forwardRef( const parsed = JSON.parse(jsonString); onChange(parsed); setJsonError(undefined); - } catch { - // Don't set error during normal typing + } catch (err) { + // For invalid JSON, set error and reset to default if it's clearly malformed + const errorMessage = + err instanceof Error ? err.message : "Invalid JSON"; + setJsonError(errorMessage); + + // Reset to default for clearly invalid JSON (not just incomplete typing) + const trimmed = jsonString.trim(); + if (trimmed.length > 5 && !trimmed.match(/^[\s\[{]/)) { + onChange(generateDefaultValue(schema)); + } } }, 300); }, @@ -164,7 +173,12 @@ const DynamicJsonForm = forwardRef( try { const jsonStr = rawJsonValue.trim(); if (!jsonStr) return { isValid: true, error: null }; - JSON.parse(jsonStr); + const parsed = JSON.parse(jsonStr); + // Clear any pending debounced update and immediately update parent + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + onChange(parsed); setJsonError(undefined); return { isValid: true, error: null }; } catch (err) { diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index c73fcd09..31979f0d 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -66,10 +66,20 @@ const ToolsTab = ({ const [isToolRunning, setIsToolRunning] = useState(false); const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false); const [isMetaExpanded, setIsMetaExpanded] = useState(false); + const [hasValidationErrors, setHasValidationErrors] = useState(false); const formRefs = useRef>({}); const { toast } = useToast(); const { copied, setCopied } = useCopy(); + // Function to check if any form has validation errors + const checkValidationErrors = () => { + const errors = Object.values(formRefs.current).some( + (ref) => ref && !ref.validateJson().isValid, + ); + setHasValidationErrors(errors); + return errors; + }; + useEffect(() => { const params = Object.entries( selectedTool?.inputSchema.properties ?? [], @@ -82,6 +92,12 @@ const ToolsTab = ({ ), ]); setParams(Object.fromEntries(params)); + + // Reset validation errors when switching tools + setHasValidationErrors(false); + + // Clear form refs for the previous tool + formRefs.current = {}; }, [selectedTool]); return ( @@ -199,6 +215,8 @@ const ToolsTab = ({ ...params, [key]: newValue, }); + // Check validation after a short delay to allow form to update + setTimeout(checkValidationErrors, 100); }} />

@@ -235,6 +253,8 @@ const ToolsTab = ({ ...params, [key]: newValue, }); + // Check validation after a short delay to allow form to update + setTimeout(checkValidationErrors, 100); }} />
@@ -317,10 +337,7 @@ const ToolsTab = ({
- {error ? ( - - - Error - {error} - - ) : selectedTool ? ( + {selectedTool ? (
+ {error && ( + + + Error + {error} + + )}

{selectedTool.description}

From 0a60c58d4fb12d0a781f8024a75dfee9071a5135 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 30 Sep 2025 15:41:09 -0400 Subject: [PATCH 36/40] Bump Inspector version to 0.17.0 - New feature: Connection Type - Reverts "fix: filter out client_secret when storing OAuth client information" - Fixes STDIO "Headers is undefined" message when connecting to an SSE server. --- cli/package.json | 2 +- client/package.json | 2 +- package-lock.json | 16 ++++++++-------- package.json | 8 ++++---- server/package.json | 2 +- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/cli/package.json b/cli/package.json index 065f9bd6..ddeb3b93 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.16.8", + "version": "0.17.0", "description": "CLI for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/client/package.json b/client/package.json index 7a4c2cf0..6881c1f7 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.16.8", + "version": "0.17.0", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/package-lock.json b/package-lock.json index cb1ee6af..2302e4f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.16.8", + "version": "0.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "0.16.8", + "version": "0.17.0", "license": "MIT", "workspaces": [ "client", @@ -14,9 +14,9 @@ "cli" ], "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.16.8", - "@modelcontextprotocol/inspector-client": "^0.16.8", - "@modelcontextprotocol/inspector-server": "^0.16.8", + "@modelcontextprotocol/inspector-cli": "^0.17.0", + "@modelcontextprotocol/inspector-client": "^0.17.0", + "@modelcontextprotocol/inspector-server": "^0.17.0", "@modelcontextprotocol/sdk": "^1.18.0", "concurrently": "^9.2.0", "node-fetch": "^3.3.2", @@ -47,7 +47,7 @@ }, "cli": { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.16.8", + "version": "0.17.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.18.0", @@ -70,7 +70,7 @@ }, "client": { "name": "@modelcontextprotocol/inspector-client", - "version": "0.16.8", + "version": "0.17.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.18.0", @@ -11875,7 +11875,7 @@ }, "server": { "name": "@modelcontextprotocol/inspector-server", - "version": "0.16.8", + "version": "0.17.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.18.0", diff --git a/package.json b/package.json index 57c42f0f..ae9b73d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.16.8", + "version": "0.17.0", "description": "Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -47,9 +47,9 @@ "check-version": "node scripts/check-version-consistency.js" }, "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.16.8", - "@modelcontextprotocol/inspector-client": "^0.16.8", - "@modelcontextprotocol/inspector-server": "^0.16.8", + "@modelcontextprotocol/inspector-cli": "^0.17.0", + "@modelcontextprotocol/inspector-client": "^0.17.0", + "@modelcontextprotocol/inspector-server": "^0.17.0", "@modelcontextprotocol/sdk": "^1.18.0", "concurrently": "^9.2.0", "node-fetch": "^3.3.2", diff --git a/server/package.json b/server/package.json index b6b9a9b2..55632fe1 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-server", - "version": "0.16.8", + "version": "0.17.0", "description": "Server-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 34534ed062e55dd36376e291cdcf158a7294b5c2 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Tue, 30 Sep 2025 15:17:10 -0600 Subject: [PATCH 37/40] fix: update default authorization header to include empty Bearer token and improve validation logic --- client/src/App.tsx | 8 +++++++- client/src/lib/hooks/__tests__/useConnection.test.tsx | 8 ++++---- client/src/lib/hooks/useConnection.ts | 8 ++++++-- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 1cdea32c..b7fc607b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -165,7 +165,13 @@ const App = () => { } // Default to empty array - return []; + return [ + { + name: "Authorization", + value: "Bearer ", + enabled: false, + }, + ]; }); const [pendingSampleRequests, setPendingSampleRequests] = useState< diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 8d8a1f8b..a9ba6825 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -914,14 +914,14 @@ describe("useConnection", () => { expect(headers).toHaveProperty("Authorization", "Bearer mock-token"); }); - test("replaces empty Bearer token placeholder with OAuth token", async () => { + test("warns of enabled empty Bearer token", async () => { // This test prevents regression of the bug where default "Bearer " header // prevented OAuth token injection, causing infinite auth loops const customHeaders: CustomHeaders = [ { name: "Authorization", value: "Bearer ", // Empty Bearer token placeholder - enabled: true, + enabled: true, // enabled }, ]; @@ -937,8 +937,8 @@ describe("useConnection", () => { }); const headers = mockSSETransport.options?.requestInit?.headers; - // Should replace the empty "Bearer " with actual OAuth token - expect(headers).toHaveProperty("Authorization", "Bearer mock-token"); + + expect(headers).toHaveProperty("Authorization", "Bearer"); // Should not have the x-custom-auth-headers since Authorization is standard expect(headers).not.toHaveProperty("x-custom-auth-headers"); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index d32c7ffa..d7b7f4b5 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -402,7 +402,7 @@ export function useConnection({ const isEmptyAuthHeader = (header: CustomHeaders[number]) => header.name.trim().toLowerCase() === "authorization" && - header.value.trim() === "Bearer"; + header.value.trim().toLowerCase() === "bearer"; // Check for empty Authorization headers and show validation error const hasEmptyAuthHeader = finalHeaders.some( @@ -418,7 +418,11 @@ export function useConnection({ }); } - const needsOAuthToken = finalHeaders.length === 0 || hasEmptyAuthHeader; + const needsOAuthToken = !finalHeaders.some( + (header) => + header.enabled && + header.name.trim().toLowerCase() === "authorization", + ); if (needsOAuthToken) { const oauthToken = (await serverAuthProvider.tokens())?.access_token; From 5b4edefbc711e4d63f2fd52b1c3b6ad7586b24cf Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Tue, 30 Sep 2025 19:06:19 -0400 Subject: [PATCH 38/40] Update client/src/lib/hooks/useConnection.ts --- client/src/lib/hooks/useConnection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index d7b7f4b5..37073e9b 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -413,7 +413,7 @@ export function useConnection({ toast({ title: "Invalid Authorization Header", description: - "Authorization header is enabled but empty. Please add a token or disable the header. It will be added automatically.", + "Authorization header is enabled but empty. Please add a token or disable the header.", variant: "destructive", }); } From 6e27b5d5302cca7a149ff83525ab01d9afda75a8 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 4 Oct 2025 13:23:33 -0400 Subject: [PATCH 39/40] In #544 a change to cli/src/index.ts was introduced that reads package.json from one level up. It works if the project is checked out locally, but not in the npm package, because the package.json of the workspace packages is not included in the inspector package. We can get that from one level up, the inpsector's own package, and so this change does that. --- cli/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/index.ts b/cli/src/index.ts index a7c5896d..70c28656 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -31,7 +31,7 @@ type JsonValue = | JsonValue[] | { [key: string]: JsonValue }; -import packageJson from "../package.json" with { type: "json" }; +import packageJson from "../../package.json" with { type: "json" }; type Args = { target: string[]; From 19a7d3752c1b2f644b984b3e57718eb7065f18da Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 4 Oct 2025 14:54:07 -0400 Subject: [PATCH 40/40] Depending upon whether it is being invoked from the npm package @modelcontextprotocol/inspector or @modelcontextprotocol/inspector-cli the location of package.json will either be in ../ or ../../ * In cli/src/index.ts - load package.json from the parent folder or parent's parent depending on file existence in parent. - move the load of package.json into callMethod function, which is the only place it is used, and is closer to where it is referenced. --- cli/src/index.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cli/src/index.ts b/cli/src/index.ts index 70c28656..90c9f5e6 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import * as fs from "fs"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { Command } from "commander"; import { @@ -31,8 +32,6 @@ type JsonValue = | JsonValue[] | { [key: string]: JsonValue }; -import packageJson from "../../package.json" with { type: "json" }; - type Args = { target: string[]; method?: string; @@ -101,6 +100,15 @@ function createTransportOptions( } async function callMethod(args: Args): Promise { + // Read package.json to get name and version for client identity + const pathA = "../package.json"; // We're in package @modelcontextprotocol/inspector-cli + const pathB = "../../package.json"; // We're in package @modelcontextprotocol/inspector + let packageJson: { name: string; version: string }; + let packageJsonData = await import(fs.existsSync(pathA) ? pathA : pathB, { + with: { type: "json" }, + }); + packageJson = packageJsonData.default; + const transportOptions = createTransportOptions( args.target, args.transport,