From d65ed01922b64afff5790460046d2ef09c238563 Mon Sep 17 00:00:00 2001 From: Nandha Reddy Date: Thu, 19 Jun 2025 02:32:40 +1000 Subject: [PATCH 01/44] 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/44] 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/44] 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/44] 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/44] 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 7b338b3ea3fa209f3d8bea94241a4c1694cb5180 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 16 Sep 2025 03:39:40 +0000 Subject: [PATCH 19/44] chore: bump version to 0.16.8 Co-authored-by: Ola Hungerford --- 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 caf21a3e..065f9bd6 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.16.7", + "version": "0.16.8", "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 db4358fd..7a4c2cf0 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.16.7", + "version": "0.16.8", "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 d422148b..31234e7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.16.7", + "version": "0.16.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "0.16.7", + "version": "0.16.8", "license": "MIT", "workspaces": [ "client", @@ -14,9 +14,9 @@ "cli" ], "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.16.7", - "@modelcontextprotocol/inspector-client": "^0.16.7", - "@modelcontextprotocol/inspector-server": "^0.16.7", + "@modelcontextprotocol/inspector-cli": "^0.16.8", + "@modelcontextprotocol/inspector-client": "^0.16.8", + "@modelcontextprotocol/inspector-server": "^0.16.8", "@modelcontextprotocol/sdk": "^1.18.0", "concurrently": "^9.2.0", "open": "^10.2.0", @@ -46,7 +46,7 @@ }, "cli": { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.16.7", + "version": "0.16.8", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.18.0", @@ -69,7 +69,7 @@ }, "client": { "name": "@modelcontextprotocol/inspector-client", - "version": "0.16.7", + "version": "0.16.8", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.18.0", @@ -11783,7 +11783,7 @@ }, "server": { "name": "@modelcontextprotocol/inspector-server", - "version": "0.16.7", + "version": "0.16.8", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.18.0", diff --git a/package.json b/package.json index 48609a20..918a2222 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.16.7", + "version": "0.16.8", "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.7", - "@modelcontextprotocol/inspector-client": "^0.16.7", - "@modelcontextprotocol/inspector-server": "^0.16.7", + "@modelcontextprotocol/inspector-cli": "^0.16.8", + "@modelcontextprotocol/inspector-client": "^0.16.8", + "@modelcontextprotocol/inspector-server": "^0.16.8", "@modelcontextprotocol/sdk": "^1.18.0", "concurrently": "^9.2.0", "open": "^10.2.0", diff --git a/server/package.json b/server/package.json index 528bee70..b6b9a9b2 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-server", - "version": "0.16.7", + "version": "0.16.8", "description": "Server-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 5ef78470dea707e3cf0f9ddb9ee3246647f49eaf Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 16 Sep 2025 17:26:29 -0400 Subject: [PATCH 20/44] * In App.tsx - Add state for connectionType - Fetch connectionType from localstorage if present * In Sidebar.tsx - Add dropdown for connectionType, only visible if transport type is not stdio * In Sidebar.test.tsx - set connectionType to "proxy" * In useConnection.ts - add connectionType param, defaulting to "proxy" - add state for mcpSessionId - Only check proxy health if the connection type is proxy - create a serverUrl variable which will either be the proxy url OR the server url depending upon the connection type - added captureResponseHeaders function to extract the protocol-related headers from a response. - when creating transport options and connectionType is "direct" and transportType is not "stdio", - add the appropriate `Accept` and `Content-Type` headers - capture the protocol related response headers and store them in the custom fetch handler - set serverUrl to be the server url, not the proxy url - if connection type is proxy set serverUrl to the mcpProxyUrl - use serverUrl instead of mcpProxyUrl for the transport instantiation * In useConnection.test.tsx - replaced test "sends X-MCP-Proxy-Auth header when proxy auth token is configured" with two tests: - "sends X-MCP-Proxy-Auth header when proxy auth token is configured for 'proxy' connectionType" - "does NOT send X-MCP-Proxy-Auth header when proxy auth token is configured for 'direct' connectionType" - added Connection URL Verification section with 2 tests - uses server URL directly when connectionType is 'direct'" - "uses proxy server URL when connectionType is 'proxy'" --- client/src/App.tsx | 15 + client/src/components/Sidebar.tsx | 28 ++ .../src/components/__tests__/Sidebar.test.tsx | 2 + .../hooks/__tests__/useConnection.test.tsx | 107 ++++++- client/src/lib/hooks/useConnection.ts | 299 ++++++++++++------ 5 files changed, 345 insertions(+), 106 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 4c35b0ec..6c9ae333 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -107,6 +107,14 @@ const App = () => { const [transportType, setTransportType] = useState< "stdio" | "sse" | "streamable-http" >(getInitialTransportType); + const [connectionType, setConnectionType] = useState<"direct" | "proxy">( + () => { + return ( + (localStorage.getItem("lastConnectionType") as "direct" | "proxy") || + "proxy" + ); + }, + ); const [logLevel, setLogLevel] = useState("debug"); const [notifications, setNotifications] = useState([]); const [roots, setRoots] = useState([]); @@ -254,6 +262,7 @@ const App = () => { oauthClientId, oauthScope, config, + connectionType, onNotification: (notification) => { setNotifications((prev) => [...prev, notification as ServerNotification]); }, @@ -338,6 +347,10 @@ const App = () => { localStorage.setItem("lastTransportType", transportType); }, [transportType]); + useEffect(() => { + localStorage.setItem("lastConnectionType", connectionType); + }, [connectionType]); + useEffect(() => { if (bearerToken) { localStorage.setItem("lastBearerToken", bearerToken); @@ -882,6 +895,8 @@ const App = () => { logLevel={logLevel} sendLogLevelRequest={sendLogLevelRequest} loggingSupported={!!serverCapabilities?.logging || false} + connectionType={connectionType} + setConnectionType={setConnectionType} />
void; + connectionType: "direct" | "proxy"; + setConnectionType: (type: "direct" | "proxy") => void; } const Sidebar = ({ @@ -94,6 +96,8 @@ const Sidebar = ({ loggingSupported, config, setConfig, + connectionType, + setConnectionType, }: SidebarProps) => { const [theme, setTheme] = useTheme(); const [showEnvVars, setShowEnvVars] = useState(false); @@ -313,6 +317,30 @@ const Sidebar = ({ /> )}
+ + {/* Connection Type switch - only visible for non-STDIO transport types */} +
+ + +
)} diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index 3a23daaf..4684eb63 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -59,6 +59,8 @@ describe("Sidebar", () => { loggingSupported: true, config: DEFAULT_INSPECTOR_CONFIG, setConfig: jest.fn(), + connectionType: "proxy" as const, + setConnectionType: jest.fn(), }; const renderSidebar = (props = {}) => { diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index a94f2386..b16eaa3d 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -18,6 +18,9 @@ import { CustomHeaders } from "../../types/customHeaders"; // Mock fetch global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({ status: "ok" }), + headers: { + get: jest.fn().mockReturnValue(null), + }, }); // Mock the SDK dependencies @@ -574,9 +577,10 @@ describe("useConnection", () => { mockStreamableHTTPTransport.options = undefined; }); - test("sends X-MCP-Proxy-Auth header when proxy auth token is configured", async () => { + test("sends X-MCP-Proxy-Auth header when proxy auth token is configured for proxy connectionType", async () => { const propsWithProxyAuth = { ...defaultProps, + connectionType: "proxy" as const, config: { ...DEFAULT_INSPECTOR_CONFIG, MCP_PROXY_AUTH_TOKEN: { @@ -626,6 +630,56 @@ describe("useConnection", () => { ).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token"); }); + test("does NOT send X-MCP-Proxy-Auth header when proxy auth token is configured for direct connectionType", async () => { + const propsWithProxyAuth = { + ...defaultProps, + connectionType: "direct" as const, + config: { + ...DEFAULT_INSPECTOR_CONFIG, + MCP_PROXY_AUTH_TOKEN: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN, + value: "test-proxy-token", + }, + }, + }; + + const { result } = renderHook(() => useConnection(propsWithProxyAuth)); + + await act(async () => { + await result.current.connect(); + }); + + // Check that the transport was created with the correct headers + expect(mockSSETransport.options).toBeDefined(); + expect(mockSSETransport.options?.requestInit).toBeDefined(); + + // Verify that X-MCP-Proxy-Auth header is NOT present for direct connections + expect(mockSSETransport.options?.requestInit?.headers).not.toHaveProperty( + "X-MCP-Proxy-Auth", + ); + expect(mockSSETransport?.options?.fetch).toBeDefined(); + + // Verify the fetch function does NOT include the proxy auth header + const mockFetch = mockSSETransport.options?.fetch; + const testUrl = "http://test.com"; + await mockFetch?.(testUrl, { + headers: { + Accept: "text/event-stream", + }, + cache: "no-store", + mode: "cors", + signal: new AbortController().signal, + redirect: "follow", + credentials: "include", + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe(testUrl); + expect( + (global.fetch as jest.Mock).mock.calls[0][1].headers, + ).not.toHaveProperty("X-MCP-Proxy-Auth"); + }); + test("does NOT send Authorization header for proxy auth", async () => { const propsWithProxyAuth = { ...defaultProps, @@ -882,6 +936,57 @@ describe("useConnection", () => { }); }); + describe("Connection URL Verification", () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset the mock transport objects + mockSSETransport.url = undefined; + mockSSETransport.options = undefined; + mockStreamableHTTPTransport.url = undefined; + mockStreamableHTTPTransport.options = undefined; + }); + + test("uses server URL directly when connectionType is 'direct'", async () => { + const directProps = { + ...defaultProps, + connectionType: "direct" as const, + }; + + const { result } = renderHook(() => useConnection(directProps)); + + await act(async () => { + await result.current.connect(); + }); + + // Verify the transport was created with the direct server URL + expect(mockSSETransport.url).toBeDefined(); + expect(mockSSETransport.url?.toString()).toBe("http://localhost:8080/"); + }); + + test("uses proxy server URL when connectionType is 'proxy'", async () => { + const proxyProps = { + ...defaultProps, + connectionType: "proxy" as const, + }; + + const { result } = renderHook(() => useConnection(proxyProps)); + + await act(async () => { + await result.current.connect(); + }); + + // Verify the transport was created with a proxy server URL + expect(mockSSETransport.url).toBeDefined(); + expect(mockSSETransport.url?.pathname).toBe("/sse"); + expect(mockSSETransport.url?.searchParams.get("url")).toBe( + "http://localhost:8080", + ); + expect(mockSSETransport.url?.searchParams.get("transportType")).toBe( + "sse", + ); + }); + }); + describe("OAuth Error Handling with Scope Discovery", () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index f47f0d15..1341b0c2 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -70,6 +70,7 @@ interface UseConnectionOptions { oauthClientId?: string; oauthScope?: string; config: InspectorConfig; + connectionType?: "direct" | "proxy"; onNotification?: (notification: Notification) => void; onStdErrNotification?: (notification: Notification) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -91,6 +92,7 @@ export function useConnection({ oauthClientId, oauthScope, config, + connectionType = "proxy", onNotification, onPendingRequest, onElicitationRequest, @@ -110,6 +112,10 @@ export function useConnection({ { request: string; response?: string }[] >([]); const [completionsSupported, setCompletionsSupported] = useState(false); + const [mcpSessionId, setMcpSessionId] = useState(null); + const [mcpProtocolVersion, setMcpProtocolVersion] = useState( + null, + ); useEffect(() => { if (!oauthClientId) { @@ -346,6 +352,17 @@ export function useConnection({ return false; }; + const captureResponseHeaders = (response: Response): void => { + const sessionId = response.headers.get("mcp-session-id"); + const protocolVersion = response.headers.get("mcp-protocol-version"); + if (sessionId && sessionId !== mcpSessionId) { + setMcpSessionId(sessionId); + } + if (protocolVersion && protocolVersion !== mcpProtocolVersion) { + setMcpProtocolVersion(protocolVersion); + } + }; + const connect = async (_e?: unknown, retryCount: number = 0) => { const client = new Client( { @@ -363,11 +380,14 @@ export function useConnection({ }, ); - try { - await checkProxyHealth(); - } catch { - setConnectionStatus("error-connecting-to-proxy"); - return; + // Only check proxy health for proxy connections + if (connectionType === "proxy") { + try { + await checkProxyHealth(); + } catch { + setConnectionStatus("error-connecting-to-proxy"); + return; + } } let lastRequest = ""; @@ -417,115 +437,183 @@ export function useConnection({ headers["x-custom-auth-headers"] = JSON.stringify(customHeaderNames); } - // Add proxy authentication - const { token: proxyAuthToken, header: proxyAuthTokenHeader } = - getMCPProxyAuthToken(config); - const proxyHeaders: HeadersInit = {}; - if (proxyAuthToken) { - proxyHeaders[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`; - } - // Create appropriate transport let transportOptions: | StreamableHTTPClientTransportOptions | SSEClientTransportOptions; - let mcpProxyServerUrl; - switch (transportType) { - case "stdio": { - mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`); - mcpProxyServerUrl.searchParams.append("command", command); - mcpProxyServerUrl.searchParams.append("args", args); - mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env)); + let serverUrl: URL; - const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS - .value as string; - if (proxyFullAddress) { - mcpProxyServerUrl.searchParams.append( - "proxyFullAddress", - proxyFullAddress, - ); - } - transportOptions = { - authProvider: serverAuthProvider, - eventSourceInit: { - fetch: ( + // Determine connection URL based on the connection type + if (connectionType === "direct" && transportType !== "stdio") { + // Direct connection - use the provided URL directly (not available for STDIO) + serverUrl = new URL(sseUrl); + + const requestHeaders = { ...headers }; + if (mcpSessionId) { + requestHeaders["mcp-session-id"] = mcpSessionId; + } + switch (transportType) { + case "sse": + requestHeaders["Accept"] = "text/event-stream"; + requestHeaders["content-type"] = "application/json"; + transportOptions = { + fetch: async ( url: string | URL | globalThis.Request, init?: RequestInit, - ) => - fetch(url, { + ) => { + const response = await fetch(url, { ...init, - headers: { ...headers, ...proxyHeaders }, - }), - }, - requestInit: { - headers: { ...headers, ...proxyHeaders }, - }, - }; - break; + headers: requestHeaders, + }); + + // Capture protocol-related headers from response + captureResponseHeaders(response); + return response; + }, + requestInit: { + headers: requestHeaders, + }, + }; + break; + + case "streamable-http": + transportOptions = { + fetch: async ( + url: string | URL | globalThis.Request, + init?: RequestInit, + ) => { + requestHeaders["Accept"] = + "text/event-stream, application/json"; + requestHeaders["Content-Type"] = "application/json"; + const response = await fetch(url, { + headers: requestHeaders, + ...init, + }); + + // Capture protocol-related headers from response + captureResponseHeaders(response); + + return response; + }, + requestInit: { + headers: requestHeaders, + }, + // TODO these should be configurable... + reconnectionOptions: { + maxReconnectionDelay: 30000, + initialReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 2, + }, + }; + break; + } + } else { + // Proxy connection (default behavior) + // Add proxy authentication headers for proxy connections only + const { token: proxyAuthToken, header: proxyAuthTokenHeader } = + getMCPProxyAuthToken(config); + const proxyHeaders: HeadersInit = {}; + if (proxyAuthToken) { + proxyHeaders[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`; } - case "sse": { - mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`); - mcpProxyServerUrl.searchParams.append("url", sseUrl); + let mcpProxyServerUrl; + switch (transportType) { + case "stdio": { + mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`); + mcpProxyServerUrl.searchParams.append("command", command); + mcpProxyServerUrl.searchParams.append("args", args); + mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env)); - const proxyFullAddressSSE = config.MCP_PROXY_FULL_ADDRESS - .value as string; - if (proxyFullAddressSSE) { - mcpProxyServerUrl.searchParams.append( - "proxyFullAddress", - proxyFullAddressSSE, - ); + const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS + .value as string; + if (proxyFullAddress) { + mcpProxyServerUrl.searchParams.append( + "proxyFullAddress", + proxyFullAddress, + ); + } + transportOptions = { + authProvider: serverAuthProvider, + eventSourceInit: { + fetch: ( + url: string | URL | globalThis.Request, + init?: RequestInit, + ) => + fetch(url, { + ...init, + headers: { ...headers, ...proxyHeaders }, + }), + }, + requestInit: { + headers: { ...headers, ...proxyHeaders }, + }, + }; + break; } - transportOptions = { - eventSourceInit: { - fetch: ( - url: string | URL | globalThis.Request, - init?: RequestInit, - ) => - fetch(url, { - ...init, - headers: { ...headers, ...proxyHeaders }, - }), - }, - requestInit: { - headers: { ...headers, ...proxyHeaders }, - }, - }; - break; - } - case "streamable-http": - mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`); - mcpProxyServerUrl.searchParams.append("url", sseUrl); - transportOptions = { - eventSourceInit: { - fetch: ( - url: string | URL | globalThis.Request, - init?: RequestInit, - ) => - fetch(url, { - ...init, - headers: { ...headers, ...proxyHeaders }, - }), - }, - requestInit: { - headers: { ...headers, ...proxyHeaders }, - }, - // TODO these should be configurable... - reconnectionOptions: { - maxReconnectionDelay: 30000, - initialReconnectionDelay: 1000, - reconnectionDelayGrowFactor: 1.5, - maxRetries: 2, - }, - }; - break; + case "sse": { + mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`); + mcpProxyServerUrl.searchParams.append("url", sseUrl); + + const proxyFullAddressSSE = config.MCP_PROXY_FULL_ADDRESS + .value as string; + if (proxyFullAddressSSE) { + mcpProxyServerUrl.searchParams.append( + "proxyFullAddress", + proxyFullAddressSSE, + ); + } + transportOptions = { + eventSourceInit: { + fetch: ( + url: string | URL | globalThis.Request, + init?: RequestInit, + ) => + fetch(url, { + ...init, + headers: { ...headers, ...proxyHeaders }, + }), + }, + requestInit: { + headers: { ...headers, ...proxyHeaders }, + }, + }; + break; + } + + case "streamable-http": + mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`); + mcpProxyServerUrl.searchParams.append("url", sseUrl); + transportOptions = { + eventSourceInit: { + fetch: ( + url: string | URL | globalThis.Request, + init?: RequestInit, + ) => + fetch(url, { + ...init, + headers: { ...headers, ...proxyHeaders }, + }), + }, + requestInit: { + headers: { ...headers, ...proxyHeaders }, + }, + // TODO these should be configurable... + reconnectionOptions: { + maxReconnectionDelay: 30000, + initialReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 2, + }, + }; + break; + } + serverUrl = mcpProxyServerUrl as URL; + serverUrl.searchParams.append("transportType", transportType); } - (mcpProxyServerUrl as URL).searchParams.append( - "transportType", - transportType, - ); if (onNotification) { [ @@ -551,14 +639,11 @@ export function useConnection({ try { const transport = transportType === "streamable-http" - ? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, { + ? new StreamableHTTPClientTransport(serverUrl, { sessionId: undefined, ...transportOptions, }) - : new SSEClientTransport( - mcpProxyServerUrl as URL, - transportOptions, - ); + : new SSEClientTransport(serverUrl, transportOptions); await client.connect(transport as Transport); @@ -575,7 +660,9 @@ export function useConnection({ }); } catch (error) { console.error( - `Failed to connect to MCP Server via the MCP Inspector Proxy: ${mcpProxyServerUrl}:`, + connectionType === "direct" + ? `Failed to connect directly to MCP Server at: ${serverUrl}:` + : `Failed to connect to MCP Server via the MCP Inspector Proxy: ${serverUrl}:`, error, ); @@ -674,6 +761,8 @@ export function useConnection({ setConnectionStatus("disconnected"); setCompletionsSupported(false); setServerCapabilities(null); + setMcpSessionId(null); + setMcpProtocolVersion(null); }; const clearRequestHistory = () => { From f1e93e0475aeac2ebd2c841dcac42be1f0054abf Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 17 Sep 2025 14:45:58 -0400 Subject: [PATCH 21/44] * In server/index.ts - in createCustomFetch function - convert finalHeaders to headers object for node-fetch compatibility - for SSE streams, convert node stream to response body to web ReadableStream since EventSource polyfill expects web-compatible stream * In package-lock.json and package.json - add node-fetch as a dependency This fixes #600 --- package-lock.json | 92 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + server/src/index.ts | 72 +++++++++++++++++++++++++++++++++-- 3 files changed, 161 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index d422148b..92a40ccf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@modelcontextprotocol/inspector-server": "^0.16.7", "@modelcontextprotocol/sdk": "^1.18.0", "concurrently": "^9.2.0", + "node-fetch": "^3.3.2", "open": "^10.2.0", "shell-quote": "^1.8.3", "spawn-rx": "^5.1.2", @@ -5103,6 +5104,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-urls": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", @@ -5970,6 +5980,29 @@ "bser": "2.1.1" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6154,6 +6187,18 @@ "node": ">= 0.6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -8716,6 +8761,44 @@ "node": ">= 0.6" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -11504,6 +11587,15 @@ "makeerror": "1.0.12" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/package.json b/package.json index 48609a20..a1f704a2 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@modelcontextprotocol/inspector-server": "^0.16.7", "@modelcontextprotocol/sdk": "^1.18.0", "concurrently": "^9.2.0", + "node-fetch": "^3.3.2", "open": "^10.2.0", "shell-quote": "^1.8.3", "spawn-rx": "^5.1.2", diff --git a/server/src/index.ts b/server/src/index.ts index 7e966d34..88954ebc 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,6 +3,11 @@ import cors from "cors"; import { parseArgs } from "node:util"; import { parse as shellParseArgs } from "shell-quote"; +import nodeFetch, { Headers as NodeHeaders } from "node-fetch"; + +// Type-compatible wrappers for node-fetch to work with browser-style types +const fetch = nodeFetch; +const Headers = NodeHeaders; import { SSEClientTransport, @@ -231,13 +236,37 @@ const authMiddleware = ( next(); }; +/** + * Converts a Node.js ReadableStream to a web-compatible ReadableStream + * This is necessary for the EventSource polyfill which expects web streams + */ +const createWebReadableStream = (nodeStream: any): ReadableStream => { + return new ReadableStream({ + start(controller) { + nodeStream.on("data", (chunk: any) => { + controller.enqueue(chunk); + }); + nodeStream.on("end", () => { + controller.close(); + }); + nodeStream.on("error", (err: any) => { + controller.error(err); + }); + }, + }); +}; + /** * Creates a `fetch` function that merges dynamic session headers with the * headers from the actual request, ensuring that request-specific headers like - * `Content-Type` are preserved. + * `Content-Type` are preserved. For SSE requests, it also converts Node.js + * streams to web-compatible streams. */ const createCustomFetch = (headerHolder: { headers: HeadersInit }) => { - return (input: RequestInfo | URL, init?: RequestInit): Promise => { + return async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { // Determine the headers from the original request/init. // The SDK may pass a Request object or a URL and an init object. const originalHeaders = @@ -252,8 +281,43 @@ const createCustomFetch = (headerHolder: { headers: HeadersInit }) => { finalHeaders.set(key, value); }); - // This works for both `fetch(url, init)` and `fetch(request)` style calls. - return fetch(input, { ...init, headers: finalHeaders }); + // Convert Headers to a plain object for node-fetch compatibility + const headersObject: Record = {}; + finalHeaders.forEach((value, key) => { + headersObject[key] = value; + }); + + // Get the response from node-fetch (cast input and init to handle type differences) + const response = await fetch( + input as any, + { ...init, headers: headersObject } as any, + ); + + // Check if this is an SSE request by looking at the Accept header + const acceptHeader = finalHeaders.get("Accept"); + const isSSE = acceptHeader?.includes("text/event-stream"); + + if (isSSE && response.body) { + // For SSE requests, we need to convert the Node.js stream to a web ReadableStream + // because the EventSource polyfill expects web-compatible streams + const webStream = createWebReadableStream(response.body); + + // Create a new response with the web-compatible stream + // Convert node-fetch headers to plain object for web Response compatibility + const responseHeaders: Record = {}; + response.headers.forEach((value: string, key: string) => { + responseHeaders[key] = value; + }); + + return new Response(webStream, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }) as Response; + } + + // For non-SSE requests, return the response as-is (cast to handle type differences) + return response as unknown as Response; }; }; From 6b7c7526841ca7c94dbd84473b3e0e7d52f95107 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 18 Sep 2025 17:42:37 -0400 Subject: [PATCH 22/44] * In Sidebar.tsx - Add tooltip over the Connection Type field --- client/src/components/Sidebar.tsx | 51 ++++++++++++++++++------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index e054fe8b..889380d8 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -108,6 +108,8 @@ const Sidebar = ({ const [copiedServerFile, setCopiedServerFile] = useState(false); const { toast } = useToast(); + const connectionTypeTip = + "Connect to server directly (requires CORS config on server) or via MCP Inspector Proxy"; // Reusable error reporter for copy actions const reportError = useCallback( (error: unknown) => { @@ -319,28 +321,33 @@ const Sidebar = ({
{/* Connection Type switch - only visible for non-STDIO transport types */} -
- - -
+ + +
+ + +
+
+ {connectionTypeTip} +
)} 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 23/44] 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 24/44] 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 40/44] 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 41/44] 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 42/44] 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 43/44] 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 44/44] 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,