From f514857a3401c05826d73cb94854886b4f0f1aa4 Mon Sep 17 00:00:00 2001 From: olaservo Date: Mon, 19 May 2025 05:31:24 -0700 Subject: [PATCH 001/281] Apply process env after defaults --- cli/src/transport.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/transport.ts b/cli/src/transport.ts index e693f2460..22dde73b0 100644 --- a/cli/src/transport.ts +++ b/cli/src/transport.ts @@ -38,8 +38,8 @@ function createStdioTransport(options: TransportOptions): Transport { const defaultEnv = getDefaultEnvironment(); const env: Record = { - ...processEnv, ...defaultEnv, + ...processEnv, }; const { cmd: actualCommand, args: actualArgs } = findActualExecutable( From f1401b143660aac71ffa3aab577ad88e11f09f89 Mon Sep 17 00:00:00 2001 From: olaservo Date: Mon, 19 May 2025 10:14:26 -0700 Subject: [PATCH 002/281] Apply process env after defaults for UI mode, too --- server/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index c967b60c7..97dbe9419 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -59,8 +59,8 @@ const createTransport = async (req: express.Request): Promise => { if (transportType === "stdio") { const command = query.command as string; const origArgs = shellParseArgs(query.args as string) as string[]; - const queryEnv = query.env ? JSON.parse(query.env as string) : {}; - const env = { ...process.env, ...defaultEnvironment, ...queryEnv }; + const queryEnv = query.env ? JSON.parse(query.env as string) : {}; + const env = { ...defaultEnvironment, ...process.env, ...queryEnv }; const { cmd, args } = findActualExecutable(command, origArgs); From 9655841399999fa51f4e16a30511901428428984 Mon Sep 17 00:00:00 2001 From: olaservo Date: Tue, 27 May 2025 09:01:29 -0700 Subject: [PATCH 003/281] Fix formatting --- server/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 97dbe9419..c113d1ea9 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -59,8 +59,8 @@ const createTransport = async (req: express.Request): Promise => { if (transportType === "stdio") { const command = query.command as string; const origArgs = shellParseArgs(query.args as string) as string[]; - const queryEnv = query.env ? JSON.parse(query.env as string) : {}; - const env = { ...defaultEnvironment, ...process.env, ...queryEnv }; + const queryEnv = query.env ? JSON.parse(query.env as string) : {}; + const env = { ...defaultEnvironment, ...process.env, ...queryEnv }; const { cmd, args } = findActualExecutable(command, origArgs); From d65ed01922b64afff5790460046d2ef09c238563 Mon Sep 17 00:00:00 2001 From: Nandha Reddy Date: Thu, 19 Jun 2025 02:32:40 +1000 Subject: [PATCH 004/281] 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 6a5993c32..fe2cfd2db 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 8a7f65785..1ce034fbf 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 return ( { try { const parsed = JSON.parse(newValue); @@ -286,199 +398,98 @@ const DynamicJsonForm = forwardRef(({ /> ); } + default: + return null; + } + }; - 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; + const handleFieldChange = (path: string[], fieldValue: JsonValue) => { + if (path.length === 0) { + onChange(fieldValue); + return; + } - // If the array items are simple, render as form fields, otherwise use JSON editor - if (isSimpleObject(propSchema.items)) { - return ( -
- {propSchema.description && ( -

- {propSchema.description} -

- )} + try { + const newValue = updateValueAtPath(value, path, fieldValue); + onChange(newValue); + } catch (error) { + console.error("Failed to update form value:", error); + onChange(value); + } + }; - {propSchema.items?.description && ( -

- Items: {propSchema.items.description} -

- )} + const shouldUseJsonMode = + schema.type === "object" && + (!schema.properties || Object.keys(schema.properties).length === 0); -
- {arrayValue.map((item, index) => ( -
- {renderFormFields( - propSchema.items as JsonSchemaType, - item, - [...path, index.toString()], - depth + 1, - )} - -
- ))} - -
-
- ); - } + useEffect(() => { + if (shouldUseJsonMode && !isJsonMode) { + setIsJsonMode(true); + } + }, [shouldUseJsonMode, isJsonMode]); - // For complex arrays, fall back to JSON editor - return ( + return ( +
+
+ {isJsonMode && ( + + )} + {!isOnlyJSON && ( + + )} +
+ + {isJsonMode ? ( { - try { - const parsed = JSON.parse(newValue); - handleFieldChange(path, parsed); - setJsonError(undefined); - } catch (err) { - setJsonError( - err instanceof Error ? err.message : "Invalid JSON", - ); - } + // Always update local state + setRawJsonValue(newValue); + + // Use the debounced function to attempt parsing and updating parent + debouncedUpdateParent(newValue); }} 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 && ( - + ) : // 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 9f0d20083..7258eaa84 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 && (
)} - +
+ + +
Date: Fri, 1 Aug 2025 20:40:28 -0700 Subject: [PATCH 014/281] Fix formatting --- cli/scripts/cli-tests.js | 1 - cli/scripts/cli-tool-tests.js | 17 ++++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js index c2b8b2cb0..24a99b377 100755 --- a/cli/scripts/cli-tests.js +++ b/cli/scripts/cli-tests.js @@ -451,7 +451,6 @@ async function runTests() { "tools/list", ); - console.log( `\n${colors.YELLOW}=== Running Resource-Related Tests ===${colors.NC}`, ); diff --git a/cli/scripts/cli-tool-tests.js b/cli/scripts/cli-tool-tests.js index 41e0a7871..b06aea940 100644 --- a/cli/scripts/cli-tool-tests.js +++ b/cli/scripts/cli-tool-tests.js @@ -26,17 +26,21 @@ let FAILED_TESTS = 0; let SKIPPED_TESTS = 0; let TOTAL_TESTS = 0; -console.log( - `${colors.YELLOW}=== MCP Inspector CLI Tool Tests ===${colors.NC}`, -); +console.log(`${colors.YELLOW}=== MCP Inspector CLI Tool Tests ===${colors.NC}`); console.log( `${colors.BLUE}This script tests the MCP Inspector CLI's tool-related functionality:${colors.NC}`, ); console.log(`${colors.BLUE}- Tool discovery and listing${colors.NC}`); -console.log(`${colors.BLUE}- JSON argument parsing (strings, numbers, booleans, objects, arrays)${colors.NC}`); +console.log( + `${colors.BLUE}- JSON argument parsing (strings, numbers, booleans, objects, arrays)${colors.NC}`, +); console.log(`${colors.BLUE}- Tool schema validation${colors.NC}`); -console.log(`${colors.BLUE}- Tool execution with various argument types${colors.NC}`); -console.log(`${colors.BLUE}- Error handling for invalid tools and arguments${colors.NC}`); +console.log( + `${colors.BLUE}- Tool execution with various argument types${colors.NC}`, +); +console.log( + `${colors.BLUE}- Error handling for invalid tools and arguments${colors.NC}`, +); console.log(`\n`); // Get directory paths @@ -373,7 +377,6 @@ async function runTests() { 'message="null"', ); - // Test 14: Multiple arguments with mixed types (using add tool) await runBasicTest( "json_args_multiple_mixed", From f73d8e6f8a380c69d848c23728333b15e8a5591b Mon Sep 17 00:00:00 2001 From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com> Date: Sat, 2 Aug 2025 15:59:45 +0200 Subject: [PATCH 015/281] fix: support FastMCP union types in tool parameter forms --- client/src/components/ToolsTab.test.tsx | 720 ++++++++++++++++++++++++ client/src/components/ToolsTab.tsx | 13 +- client/src/utils/jsonUtils.ts | 11 +- client/src/utils/schemaUtils.ts | 27 + 4 files changed, 767 insertions(+), 4 deletions(-) create mode 100644 client/src/components/ToolsTab.test.tsx diff --git a/client/src/components/ToolsTab.test.tsx b/client/src/components/ToolsTab.test.tsx new file mode 100644 index 000000000..cb662d92b --- /dev/null +++ b/client/src/components/ToolsTab.test.tsx @@ -0,0 +1,720 @@ +import { render, screen, fireEvent, act } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { describe, it, jest, beforeEach } from "@jest/globals"; +import ToolsTab from "../ToolsTab"; +import { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { Tabs } from "@/components/ui/tabs"; +import { cacheToolOutputSchemas } from "@/utils/schemaUtils"; +import { within } from "@testing-library/react"; + +describe("ToolsTab", () => { + beforeEach(() => { + // Clear the output schema cache before each test + cacheToolOutputSchemas([]); + }); + + const mockTools: Tool[] = [ + { + name: "tool1", + description: "First tool", + inputSchema: { + type: "object" as const, + properties: { + num: { type: "number" as const }, + }, + }, + }, + { + name: "tool3", + description: "Integer tool", + inputSchema: { + type: "object" as const, + properties: { + count: { type: "integer" as const }, + }, + }, + }, + { + name: "tool2", + description: "Second tool", + inputSchema: { + type: "object" as const, + properties: { + num: { type: "number" as const }, + }, + }, + }, + ]; + + const defaultProps = { + tools: mockTools, + listTools: jest.fn(), + clearTools: jest.fn(), + callTool: jest.fn(async () => {}), + selectedTool: null, + setSelectedTool: jest.fn(), + toolResult: null, + nextCursor: "", + error: null, + resourceContent: {}, + onReadResource: jest.fn(), + }; + + const renderToolsTab = (props = {}) => { + return render( + + + , + ); + }; + + it("should reset input values when switching tools", async () => { + const { rerender } = renderToolsTab({ + selectedTool: mockTools[0], + }); + + // Enter a value in the first tool's input + const input = screen.getByRole("spinbutton") as HTMLInputElement; + await act(async () => { + fireEvent.change(input, { target: { value: "42" } }); + }); + expect(input.value).toBe("42"); + + // Switch to second tool + rerender( + + + , + ); + + // Verify input is reset + const newInput = screen.getByRole("spinbutton") as HTMLInputElement; + expect(newInput.value).toBe(""); + }); + + it("should handle integer type inputs", async () => { + renderToolsTab({ + selectedTool: mockTools[1], // Use the tool with integer type + }); + + const input = screen.getByRole("spinbutton", { + name: /count/i, + }) as HTMLInputElement; + expect(input).toHaveProperty("type", "number"); + fireEvent.change(input, { target: { value: "42" } }); + expect(input.value).toBe("42"); + + const submitButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, { + count: 42, + }); + }); + + it("should handle union string|null type inputs (like FastMCP optional parameters)", async () => { + const unionTool = { + name: "searchTool", + description: "Search with optional category", + inputSchema: { + type: "object" as const, + properties: { + category: { + anyOf: [{ type: "string" as const }, { type: "null" as const }], + description: "Optional category parameter", + }, + }, + required: [], + }, + }; + + renderToolsTab({ + selectedTool: unionTool, + }); + + const input = screen.getByRole("textbox", { + name: /category/i, + }) as HTMLTextAreaElement; + + // Initially should be empty string + expect(input.value).toBe(""); + + // User types "hello" + fireEvent.change(input, { target: { value: "hello" } }); + expect(input.value).toBe("hello"); + + const submitButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(submitButton); + }); + + // Should call with the string value, not null + expect(defaultProps.callTool).toHaveBeenCalledWith(unionTool.name, { + category: "hello", + }); + }); + + it("should omit undefined optional parameters from tool calls", async () => { + const unionTool = { + name: "searchTool", + description: "Search with optional category", + inputSchema: { + type: "object" as const, + properties: { + category: { + anyOf: [{ type: "string" as const }, { type: "null" as const }], + description: "Optional category parameter", + }, + }, + required: [], + }, + }; + + renderToolsTab({ + selectedTool: unionTool, + }); + + const input = screen.getByRole("textbox", { + name: /category/i, + }) as HTMLTextAreaElement; + + // User types something then deletes it, leaving empty + fireEvent.change(input, { target: { value: "hello" } }); + fireEvent.change(input, { target: { value: "" } }); + expect(input.value).toBe(""); + + const submitButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(submitButton); + }); + + // Should omit the empty parameter entirely + expect(defaultProps.callTool).toHaveBeenCalledWith(unionTool.name, {}); + }); + + it("should allow typing negative numbers", async () => { + renderToolsTab({ + selectedTool: mockTools[0], + }); + + const input = screen.getByRole("spinbutton") as HTMLInputElement; + + // Complete the negative number + fireEvent.change(input, { target: { value: "-42" } }); + expect(input.value).toBe("-42"); + + const submitButton = screen.getByRole("button", { name: /run tool/i }); + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, { + num: -42, + }); + }); + + it("should disable button and change text while tool is running", async () => { + // Create a promise that we can resolve later + let resolvePromise: ((value: unknown) => void) | undefined; + const mockPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + // Mock callTool to return our promise + const mockCallTool = jest.fn().mockReturnValue(mockPromise); + + renderToolsTab({ + selectedTool: mockTools[0], + callTool: mockCallTool, + }); + + const submitButton = screen.getByRole("button", { name: /run tool/i }); + expect(submitButton.getAttribute("disabled")).toBeNull(); + + // Click the button and verify immediate state changes + await act(async () => { + fireEvent.click(submitButton); + }); + + // Verify button is disabled and text changed + expect(submitButton.getAttribute("disabled")).not.toBeNull(); + expect(submitButton.textContent).toBe("Running..."); + + // Resolve the promise to simulate tool completion + await act(async () => { + if (resolvePromise) { + await resolvePromise({}); + } + }); + + expect(submitButton.getAttribute("disabled")).toBeNull(); + }); + + describe("Output Schema Display", () => { + const toolWithOutputSchema: Tool = { + name: "weatherTool", + description: "Get weather", + inputSchema: { + type: "object" as const, + properties: { + city: { type: "string" as const }, + }, + }, + outputSchema: { + type: "object" as const, + properties: { + temperature: { type: "number" as const }, + humidity: { type: "number" as const }, + }, + required: ["temperature", "humidity"], + }, + }; + + it("should display output schema when tool has one", () => { + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + }); + + expect(screen.getByText("Output Schema:")).toBeInTheDocument(); + // Check for expand/collapse button + expect( + screen.getByRole("button", { name: /expand/i }), + ).toBeInTheDocument(); + }); + + it("should not display output schema section when tool doesn't have one", () => { + renderToolsTab({ + selectedTool: mockTools[0], // Tool without outputSchema + }); + + expect(screen.queryByText("Output Schema:")).not.toBeInTheDocument(); + }); + + it("should toggle output schema expansion", () => { + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + }); + + const toggleButton = screen.getByRole("button", { name: /expand/i }); + + // Click to expand + fireEvent.click(toggleButton); + expect( + screen.getByRole("button", { name: /collapse/i }), + ).toBeInTheDocument(); + + // Click to collapse + fireEvent.click(toggleButton); + expect( + screen.getByRole("button", { name: /expand/i }), + ).toBeInTheDocument(); + }); + }); + + describe("Structured Output Results", () => { + const toolWithOutputSchema: Tool = { + name: "weatherTool", + description: "Get weather", + inputSchema: { + type: "object" as const, + properties: {}, + }, + outputSchema: { + type: "object" as const, + properties: { + temperature: { type: "number" as const }, + }, + required: ["temperature"], + }, + }; + + beforeEach(() => { + // Cache the tool's output schema before each test + cacheToolOutputSchemas([toolWithOutputSchema]); + }); + + it("should display structured content when present", () => { + const structuredResult = { + content: [], + structuredContent: { + temperature: 25, + }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: structuredResult, + }); + + expect(screen.getByText("Structured Content:")).toBeInTheDocument(); + expect( + screen.getByText(/Valid according to output schema/), + ).toBeInTheDocument(); + }); + + it("should show validation error for invalid structured content", () => { + const invalidResult = { + content: [], + structuredContent: { + temperature: "25", // String instead of number + }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: invalidResult, + }); + + expect(screen.getByText(/Validation Error:/)).toBeInTheDocument(); + }); + + it("should show error when tool with output schema doesn't return structured content", () => { + const resultWithoutStructured = { + content: [{ type: "text", text: "some result" }], + // No structuredContent + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: resultWithoutStructured, + }); + + expect( + screen.getByText( + /Tool has an output schema but did not return structured content/, + ), + ).toBeInTheDocument(); + }); + + it("should show unstructured content title when both structured and unstructured exist", () => { + const resultWithBoth = { + content: [{ type: "text", text: '{"temperature": 25}' }], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: resultWithBoth, + }); + + expect(screen.getByText("Structured Content:")).toBeInTheDocument(); + expect(screen.getByText("Unstructured Content:")).toBeInTheDocument(); + }); + + it("should not show unstructured content title when only unstructured exists", () => { + const resultWithUnstructuredOnly = { + content: [{ type: "text", text: "some result" }], + }; + + renderToolsTab({ + selectedTool: mockTools[0], // Tool without output schema + toolResult: resultWithUnstructuredOnly, + }); + + expect( + screen.queryByText("Unstructured Content:"), + ).not.toBeInTheDocument(); + }); + + it("should show compatibility check when tool has output schema", () => { + const compatibleResult = { + content: [{ type: "text", text: '{"temperature": 25}' }], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: compatibleResult, + }); + + // Should show compatibility result + expect( + screen.getByText(/structured content matches/i), + ).toBeInTheDocument(); + }); + + it("should accept multiple content blocks with structured output", () => { + const multipleBlocksResult = { + content: [ + { type: "text", text: "Here is the weather data:" }, + { type: "text", text: '{"temperature": 25}' }, + { type: "text", text: "Have a nice day!" }, + ], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: multipleBlocksResult, + }); + + // Should show compatible result with multiple blocks + expect( + screen.getByText(/structured content matches.*multiple/i), + ).toBeInTheDocument(); + }); + + it("should accept mixed content types with structured output", () => { + const mixedContentResult = { + content: [ + { type: "text", text: "Weather report:" }, + { type: "text", text: '{"temperature": 25}' }, + { type: "image", data: "base64data", mimeType: "image/png" }, + ], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: mixedContentResult, + }); + + // Should render without crashing - the validation logic has been updated + expect(screen.getAllByText("weatherTool")).toHaveLength(2); + }); + + it("should reject when no text blocks match structured content", () => { + const noMatchResult = { + content: [ + { type: "text", text: "Some text" }, + { type: "text", text: '{"humidity": 60}' }, // Different structure + ], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: noMatchResult, + }); + + // Should render without crashing - the validation logic has been updated + expect(screen.getAllByText("weatherTool")).toHaveLength(2); + }); + + it("should reject when no text blocks are present", () => { + const noTextBlocksResult = { + content: [{ type: "image", data: "base64data", mimeType: "image/png" }], + structuredContent: { temperature: 25 }, + }; + + renderToolsTab({ + tools: [toolWithOutputSchema], + selectedTool: toolWithOutputSchema, + toolResult: noTextBlocksResult, + }); + + // Should render without crashing - the validation logic has been updated + expect(screen.getAllByText("weatherTool")).toHaveLength(2); + }); + + it("should not show compatibility check when tool has no output schema", () => { + const resultWithBoth = { + content: [{ type: "text", text: '{"data": "value"}' }], + structuredContent: { different: "data" }, + }; + + renderToolsTab({ + selectedTool: mockTools[0], // Tool without output schema + toolResult: resultWithBoth, + }); + + // Should not show any compatibility messages + expect( + screen.queryByText( + /structured content matches|no text blocks|no.*matches/i, + ), + ).not.toBeInTheDocument(); + }); + }); + + describe("Resource Link Content Type", () => { + it("should render resource_link content type and handle expansion", async () => { + const mockOnReadResource = jest.fn(); + const resourceContent = { + "test://static/resource/1": JSON.stringify({ + contents: [ + { + uri: "test://static/resource/1", + name: "Resource 1", + mimeType: "text/plain", + text: "Resource 1: This is a plaintext resource", + }, + ], + }), + }; + + const result = { + content: [ + { + type: "resource_link", + uri: "test://static/resource/1", + name: "Resource 1", + description: "Resource 1: plaintext resource", + mimeType: "text/plain", + }, + { + type: "resource_link", + uri: "test://static/resource/2", + name: "Resource 2", + description: "Resource 2: binary blob resource", + mimeType: "application/octet-stream", + }, + { + type: "resource_link", + uri: "test://static/resource/3", + name: "Resource 3", + description: "Resource 3: plaintext resource", + mimeType: "text/plain", + }, + ], + }; + + renderToolsTab({ + selectedTool: mockTools[0], + toolResult: result, + resourceContent, + onReadResource: mockOnReadResource, + }); + + ["1", "2", "3"].forEach((id) => { + expect( + screen.getByText(`test://static/resource/${id}`), + ).toBeInTheDocument(); + expect(screen.getByText(`Resource ${id}`)).toBeInTheDocument(); + }); + + expect(screen.getAllByText("text/plain")).toHaveLength(2); + expect(screen.getByText("application/octet-stream")).toBeInTheDocument(); + + const expandButtons = screen.getAllByRole("button", { + name: /expand resource/i, + }); + expect(expandButtons).toHaveLength(3); + expect(screen.queryByText("Resource:")).not.toBeInTheDocument(); + + expandButtons.forEach((button) => { + expect(button).toHaveAttribute("aria-expanded", "false"); + }); + + const resource1Button = screen.getByRole("button", { + name: /expand resource test:\/\/static\/resource\/1/i, + }); + + await act(async () => { + fireEvent.click(resource1Button); + }); + + expect(mockOnReadResource).toHaveBeenCalledWith( + "test://static/resource/1", + ); + expect(screen.getByText("Resource:")).toBeInTheDocument(); + expect(document.body).toHaveTextContent("contents:"); + expect(document.body).toHaveTextContent('uri:"test://static/resource/1"'); + expect(resource1Button).toHaveAttribute("aria-expanded", "true"); + + await act(async () => { + fireEvent.click(resource1Button); + }); + + expect(screen.queryByText("Resource:")).not.toBeInTheDocument(); + expect(document.body).not.toHaveTextContent("contents:"); + expect(document.body).not.toHaveTextContent( + 'uri:"test://static/resource/1"', + ); + expect(resource1Button).toHaveAttribute("aria-expanded", "false"); + expect(mockOnReadResource).toHaveBeenCalledTimes(1); + }); + }); + + describe("Meta Display", () => { + const toolWithMeta = { + name: "metaTool", + description: "Tool with meta", + inputSchema: { + type: "object" as const, + properties: { + foo: { type: "string" as const }, + }, + }, + _meta: { + author: "tester", + version: 1, + }, + } as unknown as Tool; + + it("should display meta section when tool has _meta", () => { + renderToolsTab({ + tools: [toolWithMeta], + selectedTool: toolWithMeta, + }); + + expect(screen.getByText("Meta:")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /expand/i }), + ).toBeInTheDocument(); + }); + + it("should toggle meta expansion", () => { + renderToolsTab({ + tools: [toolWithMeta], + selectedTool: toolWithMeta, + }); + + // There might be multiple Expand buttons (Output Schema, Meta). We need the one within Meta section + const metaHeading = screen.getByText("Meta:"); + const metaContainer = metaHeading.closest("div"); + expect(metaContainer).toBeTruthy(); + const toggleButton = within(metaContainer as HTMLElement).getByRole( + "button", + { name: /expand/i }, + ); + + // Expand Meta + fireEvent.click(toggleButton); + expect( + within(metaContainer as HTMLElement).getByRole("button", { + name: /collapse/i, + }), + ).toBeInTheDocument(); + + // Collapse Meta + fireEvent.click(toggleButton); + expect( + within(metaContainer as HTMLElement).getByRole("button", { + name: /expand/i, + }), + ).toBeInTheDocument(); + }); + }); + + describe("ToolResults Meta", () => { + it("should display meta information when present in toolResult", () => { + const resultWithMeta = { + content: [], + _meta: { info: "details", version: 2 }, + }; + + renderToolsTab({ + selectedTool: mockTools[0], + toolResult: resultWithMeta, + }); + + // Only ToolResults meta should be present since selectedTool has no _meta + expect(screen.getAllByText("Meta:")).toHaveLength(1); + expect(screen.getByText(/info/i)).toBeInTheDocument(); + expect(screen.getByText(/version/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 2eb682e50..2654feed9 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -7,7 +7,11 @@ import { TabsContent } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import DynamicJsonForm from "./DynamicJsonForm"; import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils"; -import { generateDefaultValue, isPropertyRequired } from "@/utils/schemaUtils"; +import { + generateDefaultValue, + isPropertyRequired, + normalizeUnionType, +} from "@/utils/schemaUtils"; import { CompatibilityCallToolResult, ListToolsResult, @@ -104,7 +108,7 @@ const ToolsTab = ({

{Object.entries(selectedTool.inputSchema.properties ?? []).map( ([key, value]) => { - const prop = value as JsonSchemaType; + const prop = normalizeUnionType(value as JsonSchemaType); const inputSchema = selectedTool.inputSchema as JsonSchemaType; const required = isPropertyRequired(key, inputSchema); @@ -148,7 +152,10 @@ const ToolsTab = ({ onChange={(e) => setParams({ ...params, - [key]: e.target.value, + [key]: + e.target.value === "" + ? undefined + : e.target.value, }) } className="mt-1" diff --git a/client/src/utils/jsonUtils.ts b/client/src/utils/jsonUtils.ts index 338b642ac..60f5b1d67 100644 --- a/client/src/utils/jsonUtils.ts +++ b/client/src/utils/jsonUtils.ts @@ -21,7 +21,16 @@ export type JsonSchemaType = { | "boolean" | "array" | "object" - | "null"; + | "null" + | ( + | "string" + | "number" + | "integer" + | "boolean" + | "array" + | "object" + | "null" + )[]; title?: string; description?: string; required?: string[]; diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index 9fc7a724a..d7210a247 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -145,6 +145,33 @@ export function isPropertyRequired( return schema.required?.includes(propertyName) ?? false; } +/** + * Normalizes union types (like string|null from FastMCP) to simple types for form rendering + * @param schema The JSON schema to normalize + * @returns A normalized schema or the original schema + */ +export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType { + // Handle anyOf with string and null (FastMCP pattern) + if ( + schema.anyOf && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "string") && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") + ) { + return { ...schema, type: "string", anyOf: undefined }; + } + + // Handle array type with string and null + if ( + Array.isArray(schema.type) && + schema.type.includes("string") && + schema.type.includes("null") + ) { + return { ...schema, type: "string" }; + } + + return schema; +} + /** * Formats a field key into a human-readable label * @param key The field key to format From edd10e48a9a4478ad15d68c767f21e4267bafe6a Mon Sep 17 00:00:00 2001 From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com> Date: Sat, 2 Aug 2025 16:01:16 +0200 Subject: [PATCH 016/281] fix: updated import path npm test passes now --- client/src/components/ToolsTab.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/ToolsTab.test.tsx b/client/src/components/ToolsTab.test.tsx index cb662d92b..42c710c90 100644 --- a/client/src/components/ToolsTab.test.tsx +++ b/client/src/components/ToolsTab.test.tsx @@ -1,7 +1,7 @@ import { render, screen, fireEvent, act } from "@testing-library/react"; import "@testing-library/jest-dom"; import { describe, it, jest, beforeEach } from "@jest/globals"; -import ToolsTab from "../ToolsTab"; +import ToolsTab from "./ToolsTab"; import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { Tabs } from "@/components/ui/tabs"; import { cacheToolOutputSchemas } from "@/utils/schemaUtils"; From bcba0dc00715a5495a799540588fd8b91ff47cbc Mon Sep 17 00:00:00 2001 From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com> Date: Sat, 2 Aug 2025 16:11:43 +0200 Subject: [PATCH 017/281] fix: removed tools tab test as its probably overkill and introduces to much new code --- client/src/components/ToolsTab.test.tsx | 720 ------------------------ 1 file changed, 720 deletions(-) delete mode 100644 client/src/components/ToolsTab.test.tsx diff --git a/client/src/components/ToolsTab.test.tsx b/client/src/components/ToolsTab.test.tsx deleted file mode 100644 index 42c710c90..000000000 --- a/client/src/components/ToolsTab.test.tsx +++ /dev/null @@ -1,720 +0,0 @@ -import { render, screen, fireEvent, act } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { describe, it, jest, beforeEach } from "@jest/globals"; -import ToolsTab from "./ToolsTab"; -import { Tool } from "@modelcontextprotocol/sdk/types.js"; -import { Tabs } from "@/components/ui/tabs"; -import { cacheToolOutputSchemas } from "@/utils/schemaUtils"; -import { within } from "@testing-library/react"; - -describe("ToolsTab", () => { - beforeEach(() => { - // Clear the output schema cache before each test - cacheToolOutputSchemas([]); - }); - - const mockTools: Tool[] = [ - { - name: "tool1", - description: "First tool", - inputSchema: { - type: "object" as const, - properties: { - num: { type: "number" as const }, - }, - }, - }, - { - name: "tool3", - description: "Integer tool", - inputSchema: { - type: "object" as const, - properties: { - count: { type: "integer" as const }, - }, - }, - }, - { - name: "tool2", - description: "Second tool", - inputSchema: { - type: "object" as const, - properties: { - num: { type: "number" as const }, - }, - }, - }, - ]; - - const defaultProps = { - tools: mockTools, - listTools: jest.fn(), - clearTools: jest.fn(), - callTool: jest.fn(async () => {}), - selectedTool: null, - setSelectedTool: jest.fn(), - toolResult: null, - nextCursor: "", - error: null, - resourceContent: {}, - onReadResource: jest.fn(), - }; - - const renderToolsTab = (props = {}) => { - return render( - - - , - ); - }; - - it("should reset input values when switching tools", async () => { - const { rerender } = renderToolsTab({ - selectedTool: mockTools[0], - }); - - // Enter a value in the first tool's input - const input = screen.getByRole("spinbutton") as HTMLInputElement; - await act(async () => { - fireEvent.change(input, { target: { value: "42" } }); - }); - expect(input.value).toBe("42"); - - // Switch to second tool - rerender( - - - , - ); - - // Verify input is reset - const newInput = screen.getByRole("spinbutton") as HTMLInputElement; - expect(newInput.value).toBe(""); - }); - - it("should handle integer type inputs", async () => { - renderToolsTab({ - selectedTool: mockTools[1], // Use the tool with integer type - }); - - const input = screen.getByRole("spinbutton", { - name: /count/i, - }) as HTMLInputElement; - expect(input).toHaveProperty("type", "number"); - fireEvent.change(input, { target: { value: "42" } }); - expect(input.value).toBe("42"); - - const submitButton = screen.getByRole("button", { name: /run tool/i }); - await act(async () => { - fireEvent.click(submitButton); - }); - - expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, { - count: 42, - }); - }); - - it("should handle union string|null type inputs (like FastMCP optional parameters)", async () => { - const unionTool = { - name: "searchTool", - description: "Search with optional category", - inputSchema: { - type: "object" as const, - properties: { - category: { - anyOf: [{ type: "string" as const }, { type: "null" as const }], - description: "Optional category parameter", - }, - }, - required: [], - }, - }; - - renderToolsTab({ - selectedTool: unionTool, - }); - - const input = screen.getByRole("textbox", { - name: /category/i, - }) as HTMLTextAreaElement; - - // Initially should be empty string - expect(input.value).toBe(""); - - // User types "hello" - fireEvent.change(input, { target: { value: "hello" } }); - expect(input.value).toBe("hello"); - - const submitButton = screen.getByRole("button", { name: /run tool/i }); - await act(async () => { - fireEvent.click(submitButton); - }); - - // Should call with the string value, not null - expect(defaultProps.callTool).toHaveBeenCalledWith(unionTool.name, { - category: "hello", - }); - }); - - it("should omit undefined optional parameters from tool calls", async () => { - const unionTool = { - name: "searchTool", - description: "Search with optional category", - inputSchema: { - type: "object" as const, - properties: { - category: { - anyOf: [{ type: "string" as const }, { type: "null" as const }], - description: "Optional category parameter", - }, - }, - required: [], - }, - }; - - renderToolsTab({ - selectedTool: unionTool, - }); - - const input = screen.getByRole("textbox", { - name: /category/i, - }) as HTMLTextAreaElement; - - // User types something then deletes it, leaving empty - fireEvent.change(input, { target: { value: "hello" } }); - fireEvent.change(input, { target: { value: "" } }); - expect(input.value).toBe(""); - - const submitButton = screen.getByRole("button", { name: /run tool/i }); - await act(async () => { - fireEvent.click(submitButton); - }); - - // Should omit the empty parameter entirely - expect(defaultProps.callTool).toHaveBeenCalledWith(unionTool.name, {}); - }); - - it("should allow typing negative numbers", async () => { - renderToolsTab({ - selectedTool: mockTools[0], - }); - - const input = screen.getByRole("spinbutton") as HTMLInputElement; - - // Complete the negative number - fireEvent.change(input, { target: { value: "-42" } }); - expect(input.value).toBe("-42"); - - const submitButton = screen.getByRole("button", { name: /run tool/i }); - await act(async () => { - fireEvent.click(submitButton); - }); - - expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, { - num: -42, - }); - }); - - it("should disable button and change text while tool is running", async () => { - // Create a promise that we can resolve later - let resolvePromise: ((value: unknown) => void) | undefined; - const mockPromise = new Promise((resolve) => { - resolvePromise = resolve; - }); - - // Mock callTool to return our promise - const mockCallTool = jest.fn().mockReturnValue(mockPromise); - - renderToolsTab({ - selectedTool: mockTools[0], - callTool: mockCallTool, - }); - - const submitButton = screen.getByRole("button", { name: /run tool/i }); - expect(submitButton.getAttribute("disabled")).toBeNull(); - - // Click the button and verify immediate state changes - await act(async () => { - fireEvent.click(submitButton); - }); - - // Verify button is disabled and text changed - expect(submitButton.getAttribute("disabled")).not.toBeNull(); - expect(submitButton.textContent).toBe("Running..."); - - // Resolve the promise to simulate tool completion - await act(async () => { - if (resolvePromise) { - await resolvePromise({}); - } - }); - - expect(submitButton.getAttribute("disabled")).toBeNull(); - }); - - describe("Output Schema Display", () => { - const toolWithOutputSchema: Tool = { - name: "weatherTool", - description: "Get weather", - inputSchema: { - type: "object" as const, - properties: { - city: { type: "string" as const }, - }, - }, - outputSchema: { - type: "object" as const, - properties: { - temperature: { type: "number" as const }, - humidity: { type: "number" as const }, - }, - required: ["temperature", "humidity"], - }, - }; - - it("should display output schema when tool has one", () => { - renderToolsTab({ - tools: [toolWithOutputSchema], - selectedTool: toolWithOutputSchema, - }); - - expect(screen.getByText("Output Schema:")).toBeInTheDocument(); - // Check for expand/collapse button - expect( - screen.getByRole("button", { name: /expand/i }), - ).toBeInTheDocument(); - }); - - it("should not display output schema section when tool doesn't have one", () => { - renderToolsTab({ - selectedTool: mockTools[0], // Tool without outputSchema - }); - - expect(screen.queryByText("Output Schema:")).not.toBeInTheDocument(); - }); - - it("should toggle output schema expansion", () => { - renderToolsTab({ - tools: [toolWithOutputSchema], - selectedTool: toolWithOutputSchema, - }); - - const toggleButton = screen.getByRole("button", { name: /expand/i }); - - // Click to expand - fireEvent.click(toggleButton); - expect( - screen.getByRole("button", { name: /collapse/i }), - ).toBeInTheDocument(); - - // Click to collapse - fireEvent.click(toggleButton); - expect( - screen.getByRole("button", { name: /expand/i }), - ).toBeInTheDocument(); - }); - }); - - describe("Structured Output Results", () => { - const toolWithOutputSchema: Tool = { - name: "weatherTool", - description: "Get weather", - inputSchema: { - type: "object" as const, - properties: {}, - }, - outputSchema: { - type: "object" as const, - properties: { - temperature: { type: "number" as const }, - }, - required: ["temperature"], - }, - }; - - beforeEach(() => { - // Cache the tool's output schema before each test - cacheToolOutputSchemas([toolWithOutputSchema]); - }); - - it("should display structured content when present", () => { - const structuredResult = { - content: [], - structuredContent: { - temperature: 25, - }, - }; - - renderToolsTab({ - tools: [toolWithOutputSchema], - selectedTool: toolWithOutputSchema, - toolResult: structuredResult, - }); - - expect(screen.getByText("Structured Content:")).toBeInTheDocument(); - expect( - screen.getByText(/Valid according to output schema/), - ).toBeInTheDocument(); - }); - - it("should show validation error for invalid structured content", () => { - const invalidResult = { - content: [], - structuredContent: { - temperature: "25", // String instead of number - }, - }; - - renderToolsTab({ - tools: [toolWithOutputSchema], - selectedTool: toolWithOutputSchema, - toolResult: invalidResult, - }); - - expect(screen.getByText(/Validation Error:/)).toBeInTheDocument(); - }); - - it("should show error when tool with output schema doesn't return structured content", () => { - const resultWithoutStructured = { - content: [{ type: "text", text: "some result" }], - // No structuredContent - }; - - renderToolsTab({ - tools: [toolWithOutputSchema], - selectedTool: toolWithOutputSchema, - toolResult: resultWithoutStructured, - }); - - expect( - screen.getByText( - /Tool has an output schema but did not return structured content/, - ), - ).toBeInTheDocument(); - }); - - it("should show unstructured content title when both structured and unstructured exist", () => { - const resultWithBoth = { - content: [{ type: "text", text: '{"temperature": 25}' }], - structuredContent: { temperature: 25 }, - }; - - renderToolsTab({ - tools: [toolWithOutputSchema], - selectedTool: toolWithOutputSchema, - toolResult: resultWithBoth, - }); - - expect(screen.getByText("Structured Content:")).toBeInTheDocument(); - expect(screen.getByText("Unstructured Content:")).toBeInTheDocument(); - }); - - it("should not show unstructured content title when only unstructured exists", () => { - const resultWithUnstructuredOnly = { - content: [{ type: "text", text: "some result" }], - }; - - renderToolsTab({ - selectedTool: mockTools[0], // Tool without output schema - toolResult: resultWithUnstructuredOnly, - }); - - expect( - screen.queryByText("Unstructured Content:"), - ).not.toBeInTheDocument(); - }); - - it("should show compatibility check when tool has output schema", () => { - const compatibleResult = { - content: [{ type: "text", text: '{"temperature": 25}' }], - structuredContent: { temperature: 25 }, - }; - - renderToolsTab({ - tools: [toolWithOutputSchema], - selectedTool: toolWithOutputSchema, - toolResult: compatibleResult, - }); - - // Should show compatibility result - expect( - screen.getByText(/structured content matches/i), - ).toBeInTheDocument(); - }); - - it("should accept multiple content blocks with structured output", () => { - const multipleBlocksResult = { - content: [ - { type: "text", text: "Here is the weather data:" }, - { type: "text", text: '{"temperature": 25}' }, - { type: "text", text: "Have a nice day!" }, - ], - structuredContent: { temperature: 25 }, - }; - - renderToolsTab({ - tools: [toolWithOutputSchema], - selectedTool: toolWithOutputSchema, - toolResult: multipleBlocksResult, - }); - - // Should show compatible result with multiple blocks - expect( - screen.getByText(/structured content matches.*multiple/i), - ).toBeInTheDocument(); - }); - - it("should accept mixed content types with structured output", () => { - const mixedContentResult = { - content: [ - { type: "text", text: "Weather report:" }, - { type: "text", text: '{"temperature": 25}' }, - { type: "image", data: "base64data", mimeType: "image/png" }, - ], - structuredContent: { temperature: 25 }, - }; - - renderToolsTab({ - tools: [toolWithOutputSchema], - selectedTool: toolWithOutputSchema, - toolResult: mixedContentResult, - }); - - // Should render without crashing - the validation logic has been updated - expect(screen.getAllByText("weatherTool")).toHaveLength(2); - }); - - it("should reject when no text blocks match structured content", () => { - const noMatchResult = { - content: [ - { type: "text", text: "Some text" }, - { type: "text", text: '{"humidity": 60}' }, // Different structure - ], - structuredContent: { temperature: 25 }, - }; - - renderToolsTab({ - tools: [toolWithOutputSchema], - selectedTool: toolWithOutputSchema, - toolResult: noMatchResult, - }); - - // Should render without crashing - the validation logic has been updated - expect(screen.getAllByText("weatherTool")).toHaveLength(2); - }); - - it("should reject when no text blocks are present", () => { - const noTextBlocksResult = { - content: [{ type: "image", data: "base64data", mimeType: "image/png" }], - structuredContent: { temperature: 25 }, - }; - - renderToolsTab({ - tools: [toolWithOutputSchema], - selectedTool: toolWithOutputSchema, - toolResult: noTextBlocksResult, - }); - - // Should render without crashing - the validation logic has been updated - expect(screen.getAllByText("weatherTool")).toHaveLength(2); - }); - - it("should not show compatibility check when tool has no output schema", () => { - const resultWithBoth = { - content: [{ type: "text", text: '{"data": "value"}' }], - structuredContent: { different: "data" }, - }; - - renderToolsTab({ - selectedTool: mockTools[0], // Tool without output schema - toolResult: resultWithBoth, - }); - - // Should not show any compatibility messages - expect( - screen.queryByText( - /structured content matches|no text blocks|no.*matches/i, - ), - ).not.toBeInTheDocument(); - }); - }); - - describe("Resource Link Content Type", () => { - it("should render resource_link content type and handle expansion", async () => { - const mockOnReadResource = jest.fn(); - const resourceContent = { - "test://static/resource/1": JSON.stringify({ - contents: [ - { - uri: "test://static/resource/1", - name: "Resource 1", - mimeType: "text/plain", - text: "Resource 1: This is a plaintext resource", - }, - ], - }), - }; - - const result = { - content: [ - { - type: "resource_link", - uri: "test://static/resource/1", - name: "Resource 1", - description: "Resource 1: plaintext resource", - mimeType: "text/plain", - }, - { - type: "resource_link", - uri: "test://static/resource/2", - name: "Resource 2", - description: "Resource 2: binary blob resource", - mimeType: "application/octet-stream", - }, - { - type: "resource_link", - uri: "test://static/resource/3", - name: "Resource 3", - description: "Resource 3: plaintext resource", - mimeType: "text/plain", - }, - ], - }; - - renderToolsTab({ - selectedTool: mockTools[0], - toolResult: result, - resourceContent, - onReadResource: mockOnReadResource, - }); - - ["1", "2", "3"].forEach((id) => { - expect( - screen.getByText(`test://static/resource/${id}`), - ).toBeInTheDocument(); - expect(screen.getByText(`Resource ${id}`)).toBeInTheDocument(); - }); - - expect(screen.getAllByText("text/plain")).toHaveLength(2); - expect(screen.getByText("application/octet-stream")).toBeInTheDocument(); - - const expandButtons = screen.getAllByRole("button", { - name: /expand resource/i, - }); - expect(expandButtons).toHaveLength(3); - expect(screen.queryByText("Resource:")).not.toBeInTheDocument(); - - expandButtons.forEach((button) => { - expect(button).toHaveAttribute("aria-expanded", "false"); - }); - - const resource1Button = screen.getByRole("button", { - name: /expand resource test:\/\/static\/resource\/1/i, - }); - - await act(async () => { - fireEvent.click(resource1Button); - }); - - expect(mockOnReadResource).toHaveBeenCalledWith( - "test://static/resource/1", - ); - expect(screen.getByText("Resource:")).toBeInTheDocument(); - expect(document.body).toHaveTextContent("contents:"); - expect(document.body).toHaveTextContent('uri:"test://static/resource/1"'); - expect(resource1Button).toHaveAttribute("aria-expanded", "true"); - - await act(async () => { - fireEvent.click(resource1Button); - }); - - expect(screen.queryByText("Resource:")).not.toBeInTheDocument(); - expect(document.body).not.toHaveTextContent("contents:"); - expect(document.body).not.toHaveTextContent( - 'uri:"test://static/resource/1"', - ); - expect(resource1Button).toHaveAttribute("aria-expanded", "false"); - expect(mockOnReadResource).toHaveBeenCalledTimes(1); - }); - }); - - describe("Meta Display", () => { - const toolWithMeta = { - name: "metaTool", - description: "Tool with meta", - inputSchema: { - type: "object" as const, - properties: { - foo: { type: "string" as const }, - }, - }, - _meta: { - author: "tester", - version: 1, - }, - } as unknown as Tool; - - it("should display meta section when tool has _meta", () => { - renderToolsTab({ - tools: [toolWithMeta], - selectedTool: toolWithMeta, - }); - - expect(screen.getByText("Meta:")).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /expand/i }), - ).toBeInTheDocument(); - }); - - it("should toggle meta expansion", () => { - renderToolsTab({ - tools: [toolWithMeta], - selectedTool: toolWithMeta, - }); - - // There might be multiple Expand buttons (Output Schema, Meta). We need the one within Meta section - const metaHeading = screen.getByText("Meta:"); - const metaContainer = metaHeading.closest("div"); - expect(metaContainer).toBeTruthy(); - const toggleButton = within(metaContainer as HTMLElement).getByRole( - "button", - { name: /expand/i }, - ); - - // Expand Meta - fireEvent.click(toggleButton); - expect( - within(metaContainer as HTMLElement).getByRole("button", { - name: /collapse/i, - }), - ).toBeInTheDocument(); - - // Collapse Meta - fireEvent.click(toggleButton); - expect( - within(metaContainer as HTMLElement).getByRole("button", { - name: /expand/i, - }), - ).toBeInTheDocument(); - }); - }); - - describe("ToolResults Meta", () => { - it("should display meta information when present in toolResult", () => { - const resultWithMeta = { - content: [], - _meta: { info: "details", version: 2 }, - }; - - renderToolsTab({ - selectedTool: mockTools[0], - toolResult: resultWithMeta, - }); - - // Only ToolResults meta should be present since selectedTool has no _meta - expect(screen.getAllByText("Meta:")).toHaveLength(1); - expect(screen.getByText(/info/i)).toBeInTheDocument(); - expect(screen.getByText(/version/i)).toBeInTheDocument(); - }); - }); -}); From 5469993e2784200ad7efe57049949e164381068d Mon Sep 17 00:00:00 2001 From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com> Date: Sat, 2 Aug 2025 16:35:42 +0200 Subject: [PATCH 018/281] fix: add boolean union type support for FastMCP optional booleans --- client/src/utils/schemaUtils.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index d7210a247..62401bf84 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -160,6 +160,15 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType { return { ...schema, type: "string", anyOf: undefined }; } + // Handle anyOf with boolean and null (FastMCP pattern) + if ( + schema.anyOf && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "boolean") && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") + ) { + return { ...schema, type: "boolean", anyOf: undefined }; + } + // Handle array type with string and null if ( Array.isArray(schema.type) && @@ -169,6 +178,15 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType { return { ...schema, type: "string" }; } + // Handle array type with boolean and null + if ( + Array.isArray(schema.type) && + schema.type.includes("boolean") && + schema.type.includes("null") + ) { + return { ...schema, type: "boolean" }; + } + return schema; } From 1d960a1b9a6092b9d3086ed9165c7a6dc5855c34 Mon Sep 17 00:00:00 2001 From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com> Date: Sun, 3 Aug 2025 10:34:19 +0200 Subject: [PATCH 019/281] fix: added explicit handling for float & int --- client/src/utils/schemaUtils.ts | 52 ++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index 62401bf84..7a1751425 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -151,42 +151,86 @@ export function isPropertyRequired( * @returns A normalized schema or the original schema */ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType { - // Handle anyOf with string and null (FastMCP pattern) + // Handle anyOf with exactly string and null (FastMCP pattern) if ( schema.anyOf && + schema.anyOf.length === 2 && schema.anyOf.some((t) => (t as JsonSchemaType).type === "string") && schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") ) { return { ...schema, type: "string", anyOf: undefined }; } - // Handle anyOf with boolean and null (FastMCP pattern) + // Handle anyOf with exactly boolean and null (FastMCP pattern) if ( schema.anyOf && + schema.anyOf.length === 2 && schema.anyOf.some((t) => (t as JsonSchemaType).type === "boolean") && schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") ) { return { ...schema, type: "boolean", anyOf: undefined }; } - // Handle array type with string and null + // Handle anyOf with exactly number and null (FastMCP pattern) + if ( + schema.anyOf && + schema.anyOf.length === 2 && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "number") && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") + ) { + return { ...schema, type: "number", anyOf: undefined }; + } + + // Handle anyOf with exactly integer and null (FastMCP pattern) + if ( + schema.anyOf && + schema.anyOf.length === 2 && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "integer") && + schema.anyOf.some((t) => (t as JsonSchemaType).type === "null") + ) { + return { ...schema, type: "integer", anyOf: undefined }; + } + + // Handle array type with exactly string and null if ( Array.isArray(schema.type) && + schema.type.length === 2 && schema.type.includes("string") && schema.type.includes("null") ) { return { ...schema, type: "string" }; } - // Handle array type with boolean and null + // Handle array type with exactly boolean and null if ( Array.isArray(schema.type) && + schema.type.length === 2 && schema.type.includes("boolean") && schema.type.includes("null") ) { return { ...schema, type: "boolean" }; } + // Handle array type with exactly number and null + if ( + Array.isArray(schema.type) && + schema.type.length === 2 && + schema.type.includes("number") && + schema.type.includes("null") + ) { + return { ...schema, type: "number" }; + } + + // Handle array type with exactly integer and null + if ( + Array.isArray(schema.type) && + schema.type.length === 2 && + schema.type.includes("integer") && + schema.type.includes("null") + ) { + return { ...schema, type: "integer" }; + } + return schema; } From d4f8e061f3dfe382d9b015b3022ec39835fd0330 Mon Sep 17 00:00:00 2001 From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com> Date: Sun, 3 Aug 2025 10:34:40 +0200 Subject: [PATCH 020/281] ci: added tests for the union type --- .../src/utils/__tests__/schemaUtils.test.ts | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/client/src/utils/__tests__/schemaUtils.test.ts b/client/src/utils/__tests__/schemaUtils.test.ts index 133e25ec0..0ecd8075a 100644 --- a/client/src/utils/__tests__/schemaUtils.test.ts +++ b/client/src/utils/__tests__/schemaUtils.test.ts @@ -1,6 +1,7 @@ import { generateDefaultValue, formatFieldLabel, + normalizeUnionType, cacheToolOutputSchemas, getToolOutputValidator, validateToolOutput, @@ -142,6 +143,189 @@ describe("formatFieldLabel", () => { }); }); +describe("normalizeUnionType", () => { + test("normalizes anyOf with string and null to string type", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "string" }, { type: "null" }], + description: "Optional string parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("string"); + expect(normalized.anyOf).toBeUndefined(); + expect(normalized.description).toBe("Optional string parameter"); + }); + + test("normalizes anyOf with boolean and null to boolean type", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "boolean" }, { type: "null" }], + description: "Optional boolean parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("boolean"); + expect(normalized.anyOf).toBeUndefined(); + expect(normalized.description).toBe("Optional boolean parameter"); + }); + + test("normalizes array type with string and null to string type", () => { + const schema: JsonSchemaType = { + type: ["string", "null"], + description: "Optional string parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("string"); + expect(normalized.description).toBe("Optional string parameter"); + }); + + test("normalizes array type with boolean and null to boolean type", () => { + const schema: JsonSchemaType = { + type: ["boolean", "null"], + description: "Optional boolean parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("boolean"); + expect(normalized.description).toBe("Optional boolean parameter"); + }); + + test("normalizes anyOf with number and null to number type", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "number" }, { type: "null" }], + description: "Optional number parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("number"); + expect(normalized.anyOf).toBeUndefined(); + expect(normalized.description).toBe("Optional number parameter"); + }); + + test("normalizes anyOf with integer and null to integer type", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "integer" }, { type: "null" }], + description: "Optional integer parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("integer"); + expect(normalized.anyOf).toBeUndefined(); + expect(normalized.description).toBe("Optional integer parameter"); + }); + + test("normalizes array type with number and null to number type", () => { + const schema: JsonSchemaType = { + type: ["number", "null"], + description: "Optional number parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("number"); + expect(normalized.description).toBe("Optional number parameter"); + }); + + test("normalizes array type with integer and null to integer type", () => { + const schema: JsonSchemaType = { + type: ["integer", "null"], + description: "Optional integer parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("integer"); + expect(normalized.description).toBe("Optional integer parameter"); + }); + + test("handles anyOf with reversed order (null first)", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "null" }, { type: "string" }], + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("string"); + expect(normalized.anyOf).toBeUndefined(); + }); + + test("leaves non-union schemas unchanged", () => { + const schema: JsonSchemaType = { + type: "string", + description: "Regular string parameter", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized).toEqual(schema); + }); + + test("leaves anyOf with non-matching types unchanged", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "string" }, { type: "number" }], + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized).toEqual(schema); + }); + + test("leaves anyOf with more than two types unchanged", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }], + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized).toEqual(schema); + }); + + test("leaves array type with non-matching types unchanged", () => { + const schema: JsonSchemaType = { + type: ["string", "number"], + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized).toEqual(schema); + }); + + test("handles schemas without type or anyOf", () => { + const schema: JsonSchemaType = { + description: "Schema without type", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized).toEqual(schema); + }); + + test("preserves other properties when normalizing", () => { + const schema: JsonSchemaType = { + anyOf: [{ type: "string" }, { type: "null" }], + description: "Optional string", + minLength: 1, + maxLength: 100, + pattern: "^[a-z]+$", + }; + + const normalized = normalizeUnionType(schema); + + expect(normalized.type).toBe("string"); + expect(normalized.anyOf).toBeUndefined(); + expect(normalized.description).toBe("Optional string"); + expect(normalized.minLength).toBe(1); + expect(normalized.maxLength).toBe(100); + expect(normalized.pattern).toBe("^[a-z]+$"); + }); +}); + describe("Output Schema Validation", () => { const mockTools: Tool[] = [ { From cc3f9df33c1cec26b9d68d5f4d2e7411f9535427 Mon Sep 17 00:00:00 2001 From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com> Date: Mon, 4 Aug 2025 09:26:24 +0200 Subject: [PATCH 021/281] fix: fix type error by handling array of types in JSON schema validation --- client/src/components/DynamicJsonForm.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 849a2aa20..aafa2933d 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -15,12 +15,22 @@ interface DynamicJsonFormProps { maxDepth?: number; } +const isTypeSupported = ( + type: JsonSchemaType["type"], + supportedTypes: string[], +): boolean => { + if (Array.isArray(type)) { + return type.every((t) => supportedTypes.includes(t)); + } + return typeof type === "string" && supportedTypes.includes(type); +}; + const isSimpleObject = (schema: JsonSchemaType): boolean => { const supportedTypes = ["string", "number", "integer", "boolean", "null"]; - if (schema.type && supportedTypes.includes(schema.type)) return true; + if (schema.type && isTypeSupported(schema.type, supportedTypes)) return true; if (schema.type === "object") { return Object.values(schema.properties ?? {}).every( - (prop) => prop.type && supportedTypes.includes(prop.type), + (prop) => prop.type && isTypeSupported(prop.type, supportedTypes), ); } if (schema.type === "array") { From fda879ccc1d5488402012d5f615602ff18c9c291 Mon Sep 17 00:00:00 2001 From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com> Date: Mon, 4 Aug 2025 09:32:24 +0200 Subject: [PATCH 022/281] ci: added test for the DynamicJsonForm --- client/src/components/DynamicJsonForm.tsx | 10 +++++++++- .../components/__tests__/DynamicJsonForm.test.tsx | 12 ++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index aafa2933d..2214add9b 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -191,7 +191,13 @@ const DynamicJsonForm = ({ const isRequired = parentSchema?.required?.includes(propertyName || "") ?? false; - switch (propSchema.type) { + let fieldType = propSchema.type; + if (Array.isArray(fieldType)) { + // Of the possible types, find the first non-null type to determine the control to render + fieldType = fieldType.find((t) => t !== "null") ?? fieldType[0]; + } + + switch (fieldType) { case "string": { if ( propSchema.oneOf && @@ -347,6 +353,8 @@ const DynamicJsonForm = ({ required={isRequired} /> ); + case "null": + return null; case "object": if (!propSchema.properties) { return ( diff --git a/client/src/components/__tests__/DynamicJsonForm.test.tsx b/client/src/components/__tests__/DynamicJsonForm.test.tsx index 22813c9bc..318fba2de 100644 --- a/client/src/components/__tests__/DynamicJsonForm.test.tsx +++ b/client/src/components/__tests__/DynamicJsonForm.test.tsx @@ -35,6 +35,18 @@ describe("DynamicJsonForm String Fields", () => { const input = screen.getByRole("textbox"); expect(input).toHaveProperty("type", "text"); }); + + it("should handle a union type of string and null", () => { + const schema: JsonSchemaType = { + type: ["string", "null"], + description: "Test string or null field", + }; + render( + , + ); + const input = screen.getByRole("textbox"); + expect(input).toHaveProperty("type", "text"); + }); }); describe("Format Support", () => { From 0194e5f62a8bc4bb3772e220db1673f1beadfaf4 Mon Sep 17 00:00:00 2001 From: Jonathan Leitschuh Date: Mon, 4 Aug 2025 14:35:33 -0400 Subject: [PATCH 023/281] Add warning for DANGEROUSLY_OMIT_AUTH usage Added warning about disabling authentication and its risks. --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 6a671f1c4..6eaa7e4be 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,16 @@ If you need to disable authentication (NOT RECOMMENDED), you can set the `DANGER DANGEROUSLY_OMIT_AUTH=true npm start ``` +--- + +**🚨 WARNING 🚨** + +Disabling authentication with `DANGEROUSLY_OMIT_AUTH` is incredibly dangerous! Disabling auth leaves your machine open to attack not just when exposed to the public internet, but also **via your web browser**. Meaning, visiting a malicious website OR viewing a malicious advertizement could allow an attacker to remotely compromise your computer. Do not disable this feature unless you truly understand the risks. + +Read more about the risks of this vulnerability on Oligo's blog: [Critical RCE Vulnerability in Anthropic MCP Inspector - CVE-2025-49596](https://www.oligo.security/blog/critical-rce-vulnerability-in-anthropic-mcp-inspector-cve-2025-49596) + +--- + You can also set the token via the `MCP_PROXY_AUTH_TOKEN` environment variable when starting the server: ```bash From acf6c1e118e3b117e06a1a5ea17aeeba3bf736de Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 30 Jul 2025 16:37:59 +0100 Subject: [PATCH 024/281] feat: Auto-detect transport type from config files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When launching the inspector with --config, automatically set the transport dropdown and server URL based on the config file contents. This eliminates the need to manually switch between stdio/sse/streamable-http in the UI. - Use discriminated union for ServerConfig to properly type different transports - Detect transport type and URL from config, pass via query params - Maintain backwards compatibility for configs without explicit 'type' field - Add comprehensive tests for the new functionality Improves UX by making the config file the single source of truth for server settings. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cli/src/cli.ts | 62 +++++++++++++++++++++++++++++++++++++-------- client/bin/start.js | 31 ++++++++++++++++++++++- 2 files changed, 81 insertions(+), 12 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 5ff1f1110..d620958bc 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -14,6 +14,8 @@ type Args = { args: string[]; envArgs: Record; cli: boolean; + transport?: "stdio" | "sse" | "streamable-http"; + serverUrl?: string; }; type CliOptions = { @@ -23,11 +25,18 @@ type CliOptions = { cli?: boolean; }; -type ServerConfig = { - command: string; - args?: string[]; - env?: Record; -}; +type ServerConfig = + | { + type: "stdio"; + command: string; + args?: string[]; + env?: Record; + } + | { + type: "sse" | "streamable-http"; + url: string; + note?: string; + }; function handleError(error: unknown): never { let message: string; @@ -74,6 +83,16 @@ async function runWebClient(args: Args): Promise { startArgs.push("-e", `${key}=${value}`); } + // Pass transport type if specified + if (args.transport) { + startArgs.push("--transport", args.transport); + } + + // Pass server URL if specified + if (args.serverUrl) { + startArgs.push("--server-url", args.serverUrl); + } + // Pass command and args (using -- to separate them) if (args.command) { startArgs.push("--", args.command, ...args.args); @@ -217,12 +236,33 @@ function parseArgs(): Args { if (options.config && options.server) { const config = loadConfigFile(options.config, options.server); - return { - command: config.command, - args: [...(config.args || []), ...finalArgs], - envArgs: { ...(config.env || {}), ...(options.e || {}) }, - cli: options.cli || false, - }; + if (config.type === "stdio") { + return { + command: config.command, + args: [...(config.args || []), ...finalArgs], + envArgs: { ...(config.env || {}), ...(options.e || {}) }, + cli: options.cli || false, + transport: "stdio", + }; + } else if (config.type === "sse" || config.type === "streamable-http") { + return { + command: "", + args: finalArgs, + envArgs: options.e || {}, + cli: options.cli || false, + transport: config.type, + serverUrl: config.url, + }; + } else { + // Backwards compatibility: if no type field, assume stdio + return { + command: (config as any).command || "", + args: [...((config as any).args || []), ...finalArgs], + envArgs: { ...((config as any).env || {}), ...(options.e || {}) }, + cli: options.cli || false, + transport: "stdio", + }; + } } // Otherwise use command line arguments diff --git a/client/bin/start.js b/client/bin/start.js index e0496cde0..93a39ad3d 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -13,7 +13,14 @@ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms, true)); } -function getClientUrl(port, authDisabled, sessionToken, serverPort) { +function getClientUrl( + port, + authDisabled, + sessionToken, + serverPort, + transport, + serverUrl, +) { const host = process.env.HOST || "localhost"; const baseUrl = `http://${host}:${port}`; @@ -24,6 +31,12 @@ function getClientUrl(port, authDisabled, sessionToken, serverPort) { if (!authDisabled) { params.set("MCP_PROXY_AUTH_TOKEN", sessionToken); } + if (transport) { + params.set("transport", transport); + } + if (serverUrl) { + params.set("serverUrl", serverUrl); + } return params.size > 0 ? `${baseUrl}/?${params.toString()}` : baseUrl; } @@ -123,6 +136,8 @@ async function startDevClient(clientOptions) { sessionToken, abort, cancelled, + transport, + serverUrl, } = clientOptions; const clientCommand = "npx"; const host = process.env.HOST || "localhost"; @@ -140,6 +155,8 @@ async function startDevClient(clientOptions) { authDisabled, sessionToken, SERVER_PORT, + transport, + serverUrl, ); // Give vite time to start before opening or logging the URL @@ -173,6 +190,8 @@ async function startProdClient(clientOptions) { sessionToken, abort, cancelled, + transport, + serverUrl, } = clientOptions; const inspectorClientPath = resolve( __dirname, @@ -187,6 +206,8 @@ async function startProdClient(clientOptions) { authDisabled, sessionToken, SERVER_PORT, + transport, + serverUrl, ); await spawnPromise("node", [inspectorClientPath], { @@ -208,6 +229,8 @@ async function main() { let command = null; let parsingFlags = true; let isDev = false; + let transport = null; + let serverUrl = null; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -233,6 +256,10 @@ async function main() { } else { envVars[envVar] = ""; } + } else if (parsingFlags && arg === "--transport" && i + 1 < args.length) { + transport = args[++i]; + } else if (parsingFlags && arg === "--server-url" && i + 1 < args.length) { + serverUrl = args[++i]; } else if (!command && !isDev) { command = arg; } else if (!isDev) { @@ -292,6 +319,8 @@ async function main() { sessionToken, abort, cancelled, + transport, + serverUrl, }; await (isDev From 1d89d750e0d2015a28b55ab8c6b35bcdaecf3a82 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 30 Jul 2025 19:29:12 +0100 Subject: [PATCH 025/281] test: Add minimal test coverage for CLI transport options - Add tests for config files with different transport types (stdio, sse, streamable-http) - Add test for backward compatibility with configs missing type field - Verify transport and serverUrl options are correctly parsed from config files - All 36 tests pass --- cli/scripts/cli-tests.js | 141 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 135 insertions(+), 6 deletions(-) diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js index 68ce3885c..857b5108b 100755 --- a/cli/scripts/cli-tests.js +++ b/cli/scripts/cli-tests.js @@ -120,6 +120,85 @@ try { const invalidConfigPath = path.join(TEMP_DIR, "invalid-config.json"); fs.writeFileSync(invalidConfigPath, '{\n "mcpServers": {\n "invalid": {'); +// Create config files with different transport types for testing +const sseConfigPath = path.join(TEMP_DIR, "sse-config.json"); +fs.writeFileSync( + sseConfigPath, + JSON.stringify( + { + mcpServers: { + "test-sse": { + type: "sse", + url: "http://localhost:3000/sse", + note: "Test SSE server", + }, + }, + }, + null, + 2, + ), +); + +const httpConfigPath = path.join(TEMP_DIR, "http-config.json"); +fs.writeFileSync( + httpConfigPath, + JSON.stringify( + { + mcpServers: { + "test-http": { + type: "streamable-http", + url: "http://localhost:3000/mcp", + note: "Test HTTP server", + }, + }, + }, + null, + 2, + ), +); + +const stdioConfigPath = path.join(TEMP_DIR, "stdio-config.json"); +fs.writeFileSync( + stdioConfigPath, + JSON.stringify( + { + mcpServers: { + "test-stdio": { + type: "stdio", + command: "npx", + args: ["@modelcontextprotocol/server-everything"], + env: { + TEST_ENV: "test-value", + }, + }, + }, + }, + null, + 2, + ), +); + +// Config without type field (backward compatibility) +const legacyConfigPath = path.join(TEMP_DIR, "legacy-config.json"); +fs.writeFileSync( + legacyConfigPath, + JSON.stringify( + { + mcpServers: { + "test-legacy": { + command: "npx", + args: ["@modelcontextprotocol/server-everything"], + env: { + LEGACY_ENV: "legacy-value", + }, + }, + }, + }, + null, + 2, + ), +); + // Function to run a basic test async function runBasicTest(testName, ...args) { const outputFile = path.join( @@ -649,6 +728,56 @@ async function runTests() { "debug", ); + console.log( + `\n${colors.YELLOW}=== Running Config Transport Type Tests ===${colors.NC}`, + ); + + // Test 25: Config with stdio transport type + await runBasicTest( + "config_stdio_type", + "--config", + stdioConfigPath, + "--server", + "test-stdio", + "--cli", + "--method", + "tools/list", + ); + + // Test 26: Config with SSE transport type (should pass transport to client) + await runBasicTest( + "config_sse_type", + "--config", + sseConfigPath, + "--server", + "test-sse", + "echo", + "test", + ); + + // Test 27: Config with streamable-http transport type + await runBasicTest( + "config_http_type", + "--config", + httpConfigPath, + "--server", + "test-http", + "echo", + "test", + ); + + // Test 28: Legacy config without type field (backward compatibility) + await runBasicTest( + "config_legacy_no_type", + "--config", + legacyConfigPath, + "--server", + "test-legacy", + "--cli", + "--method", + "tools/list", + ); + console.log( `\n${colors.YELLOW}=== Running HTTP Transport Tests ===${colors.NC}`, ); @@ -668,7 +797,7 @@ async function runTests() { await new Promise((resolve) => setTimeout(resolve, 3000)); - // Test 25: HTTP transport inferred from URL ending with /mcp + // Test 29: HTTP transport inferred from URL ending with /mcp await runBasicTest( "http_transport_inferred", "http://127.0.0.1:3001/mcp", @@ -677,7 +806,7 @@ async function runTests() { "tools/list", ); - // Test 26: HTTP transport with explicit --transport http flag + // Test 30: HTTP transport with explicit --transport http flag await runBasicTest( "http_transport_with_explicit_flag", "http://127.0.0.1:3001/mcp", @@ -688,7 +817,7 @@ async function runTests() { "tools/list", ); - // Test 27: HTTP transport with suffix and --transport http flag + // Test 31: HTTP transport with suffix and --transport http flag await runBasicTest( "http_transport_with_explicit_flag_and_suffix", "http://127.0.0.1:3001/mcp", @@ -699,7 +828,7 @@ async function runTests() { "tools/list", ); - // Test 28: SSE transport given to HTTP server (should fail) + // Test 32: SSE transport given to HTTP server (should fail) await runErrorTest( "sse_transport_given_to_http_server", "http://127.0.0.1:3001", @@ -710,7 +839,7 @@ async function runTests() { "tools/list", ); - // Test 29: HTTP transport without URL (should fail) + // Test 33: HTTP transport without URL (should fail) await runErrorTest( "http_transport_without_url", "--transport", @@ -720,7 +849,7 @@ async function runTests() { "tools/list", ); - // Test 30: SSE transport without URL (should fail) + // Test 34: SSE transport without URL (should fail) await runErrorTest( "sse_transport_without_url", "--transport", From b6dc2ceeb94737a93ea11b0550eb10be658e77b5 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 5 Aug 2025 13:30:16 +0100 Subject: [PATCH 026/281] Remove transport/serverUrl parsing from start.js Move transport configuration parsing to cli.js as suggested in PR review. The CLI entry point (cli.js) should handle config file parsing and pass parameters to start.js. --- client/bin/start.js | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/client/bin/start.js b/client/bin/start.js index 93a39ad3d..ddf3fac31 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -18,8 +18,6 @@ function getClientUrl( authDisabled, sessionToken, serverPort, - transport, - serverUrl, ) { const host = process.env.HOST || "localhost"; const baseUrl = `http://${host}:${port}`; @@ -31,12 +29,6 @@ function getClientUrl( if (!authDisabled) { params.set("MCP_PROXY_AUTH_TOKEN", sessionToken); } - if (transport) { - params.set("transport", transport); - } - if (serverUrl) { - params.set("serverUrl", serverUrl); - } return params.size > 0 ? `${baseUrl}/?${params.toString()}` : baseUrl; } @@ -136,8 +128,6 @@ async function startDevClient(clientOptions) { sessionToken, abort, cancelled, - transport, - serverUrl, } = clientOptions; const clientCommand = "npx"; const host = process.env.HOST || "localhost"; @@ -155,8 +145,6 @@ async function startDevClient(clientOptions) { authDisabled, sessionToken, SERVER_PORT, - transport, - serverUrl, ); // Give vite time to start before opening or logging the URL @@ -190,8 +178,6 @@ async function startProdClient(clientOptions) { sessionToken, abort, cancelled, - transport, - serverUrl, } = clientOptions; const inspectorClientPath = resolve( __dirname, @@ -206,8 +192,6 @@ async function startProdClient(clientOptions) { authDisabled, sessionToken, SERVER_PORT, - transport, - serverUrl, ); await spawnPromise("node", [inspectorClientPath], { @@ -229,8 +213,6 @@ async function main() { let command = null; let parsingFlags = true; let isDev = false; - let transport = null; - let serverUrl = null; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -256,10 +238,6 @@ async function main() { } else { envVars[envVar] = ""; } - } else if (parsingFlags && arg === "--transport" && i + 1 < args.length) { - transport = args[++i]; - } else if (parsingFlags && arg === "--server-url" && i + 1 < args.length) { - serverUrl = args[++i]; } else if (!command && !isDev) { command = arg; } else if (!isDev) { @@ -319,8 +297,6 @@ async function main() { sessionToken, abort, cancelled, - transport, - serverUrl, }; await (isDev From 63193ec00730e6d6d4502ceb43935cdfa277d15c Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 5 Aug 2025 13:31:40 +0100 Subject: [PATCH 027/281] Clean up CLI tests and add config parsing tests - Remove web client launch tests from CLI test suite - Add CLI-mode tests for SSE and streamable-http configs - Keep only tests that use --cli flag as intended --- cli/scripts/cli-tests.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js index 857b5108b..299c7e3d3 100755 --- a/cli/scripts/cli-tests.js +++ b/cli/scripts/cli-tests.js @@ -744,26 +744,28 @@ async function runTests() { "tools/list", ); - // Test 26: Config with SSE transport type (should pass transport to client) + // Test 26: Config with SSE transport type (CLI mode) await runBasicTest( - "config_sse_type", + "config_sse_type_cli", "--config", sseConfigPath, "--server", "test-sse", - "echo", - "test", + "--cli", + "--method", + "tools/list", ); - // Test 27: Config with streamable-http transport type + // Test 27: Config with streamable-http transport type (CLI mode) await runBasicTest( - "config_http_type", + "config_http_type_cli", "--config", httpConfigPath, "--server", "test-http", - "echo", - "test", + "--cli", + "--method", + "tools/list", ); // Test 28: Legacy config without type field (backward compatibility) From a7675d1be2690f9d602fa866e2614adca42b107d Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 5 Aug 2025 13:34:16 +0100 Subject: [PATCH 028/281] Add Playwright e2e tests for CLI argument handling - Test that transport and serverUrl parameters are correctly passed via URL - Verify SSE and streamable-http transport types are handled - Test default STDIO behavior --- client/e2e/cli-arguments.spec.ts | 61 ++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 client/e2e/cli-arguments.spec.ts diff --git a/client/e2e/cli-arguments.spec.ts b/client/e2e/cli-arguments.spec.ts new file mode 100644 index 000000000..3376a6d73 --- /dev/null +++ b/client/e2e/cli-arguments.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from "@playwright/test"; + +// These tests verify that CLI arguments correctly set URL parameters +// The CLI should parse config files and pass transport/serverUrl as URL params +test.describe("CLI Arguments @cli", () => { + test("should pass transport parameter from command line", async ({ + page, + }) => { + // Simulate: npx . --transport sse --server-url http://localhost:3000/sse + await page.goto("http://localhost:6274/?transport=sse&serverUrl=http://localhost:3000/sse"); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Verify transport dropdown shows SSE + await expect(selectTrigger).toContainText("SSE"); + + // Verify URL field is visible and populated + const urlInput = page.locator("#sse-url-input"); + await expect(urlInput).toBeVisible(); + await expect(urlInput).toHaveValue("http://localhost:3000/sse"); + }); + + test("should pass transport parameter for streamable-http", async ({ + page, + }) => { + // Simulate config with streamable-http transport + await page.goto("http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:3000/mcp"); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Verify transport dropdown shows Streamable HTTP + await expect(selectTrigger).toContainText("Streamable HTTP"); + + // Verify URL field is visible and populated + const urlInput = page.locator("#sse-url-input"); + await expect(urlInput).toBeVisible(); + await expect(urlInput).toHaveValue("http://localhost:3000/mcp"); + }); + + test("should not pass transport parameter for stdio config", async ({ + page, + }) => { + // Simulate stdio config (no transport param needed) + await page.goto("http://localhost:6274/"); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Verify transport dropdown defaults to STDIO + await expect(selectTrigger).toContainText("STDIO"); + + // Verify command/args fields are visible + await expect(page.locator("#command-input")).toBeVisible(); + await expect(page.locator("#arguments-input")).toBeVisible(); + }); +}); \ No newline at end of file From 0e2906c4c4e7088fe5fdc72ef25a877d9034e93b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 5 Aug 2025 13:35:28 +0100 Subject: [PATCH 029/281] Implement default-server support - Auto-select server when --config is used without --server - Use 'default-server' if it exists in config - Use single server if only one is defined - Show helpful error if multiple servers exist without default - Add tests for all default-server scenarios --- cli/scripts/cli-tests.js | 108 ++++++++++++++++++++++++++++++++++++--- cli/src/cli.ts | 36 ++++++++++--- 2 files changed, 132 insertions(+), 12 deletions(-) diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js index 299c7e3d3..87981dd83 100755 --- a/cli/scripts/cli-tests.js +++ b/cli/scripts/cli-tests.js @@ -780,6 +780,102 @@ async function runTests() { "tools/list", ); + console.log( + `\n${colors.YELLOW}=== Running Default Server Tests ===${colors.NC}`, + ); + + // Create config with single server for auto-selection + const singleServerConfigPath = path.join(TEMP_DIR, "single-server-config.json"); + fs.writeFileSync( + singleServerConfigPath, + JSON.stringify( + { + mcpServers: { + "only-server": { + command: "npx", + args: ["@modelcontextprotocol/server-everything"], + }, + }, + }, + null, + 2, + ), + ); + + // Create config with default-server + const defaultServerConfigPath = path.join(TEMP_DIR, "default-server-config.json"); + fs.writeFileSync( + defaultServerConfigPath, + JSON.stringify( + { + mcpServers: { + "default-server": { + command: "npx", + args: ["@modelcontextprotocol/server-everything"], + }, + "other-server": { + command: "node", + args: ["other.js"], + }, + }, + }, + null, + 2, + ), + ); + + // Create config with multiple servers (no default) + const multiServerConfigPath = path.join(TEMP_DIR, "multi-server-config.json"); + fs.writeFileSync( + multiServerConfigPath, + JSON.stringify( + { + mcpServers: { + "server1": { + command: "npx", + args: ["@modelcontextprotocol/server-everything"], + }, + "server2": { + command: "node", + args: ["other.js"], + }, + }, + }, + null, + 2, + ), + ); + + // Test 29: Config with single server auto-selection + await runBasicTest( + "single_server_auto_select", + "--config", + singleServerConfigPath, + "--cli", + "--method", + "tools/list", + ); + + // Test 30: Config with default-server auto-selection + await runBasicTest( + "default_server_auto_select", + "--config", + defaultServerConfigPath, + "--cli", + "--method", + "tools/list", + ); + + // Test 31: Config with multiple servers and no default (should fail) + await runErrorTest( + "multi_server_no_default", + "--config", + multiServerConfigPath, + "--cli", + "--method", + "tools/list", + ); + console.log( `\n${colors.YELLOW}=== Running HTTP Transport Tests ===${colors.NC}`, ); @@ -799,7 +895,7 @@ async function runTests() { await new Promise((resolve) => setTimeout(resolve, 3000)); - // Test 29: HTTP transport inferred from URL ending with /mcp + // Test 32: HTTP transport inferred from URL ending with /mcp await runBasicTest( "http_transport_inferred", "http://127.0.0.1:3001/mcp", @@ -808,7 +904,7 @@ async function runTests() { "tools/list", ); - // Test 30: HTTP transport with explicit --transport http flag + // Test 33: HTTP transport with explicit --transport http flag await runBasicTest( "http_transport_with_explicit_flag", "http://127.0.0.1:3001/mcp", @@ -819,7 +915,7 @@ async function runTests() { "tools/list", ); - // Test 31: HTTP transport with suffix and --transport http flag + // Test 34: HTTP transport with suffix and --transport http flag await runBasicTest( "http_transport_with_explicit_flag_and_suffix", "http://127.0.0.1:3001/mcp", @@ -830,7 +926,7 @@ async function runTests() { "tools/list", ); - // Test 32: SSE transport given to HTTP server (should fail) + // Test 35: SSE transport given to HTTP server (should fail) await runErrorTest( "sse_transport_given_to_http_server", "http://127.0.0.1:3001", @@ -841,7 +937,7 @@ async function runTests() { "tools/list", ); - // Test 33: HTTP transport without URL (should fail) + // Test 36: HTTP transport without URL (should fail) await runErrorTest( "http_transport_without_url", "--transport", @@ -851,7 +947,7 @@ async function runTests() { "tools/list", ); - // Test 34: SSE transport without URL (should fail) + // Test 37: SSE transport without URL (should fail) await runErrorTest( "sse_transport_without_url", "--transport", diff --git a/cli/src/cli.ts b/cli/src/cli.ts index d620958bc..cf8662e53 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -220,16 +220,40 @@ function parseArgs(): Args { // Add back any arguments that came after -- const finalArgs = [...remainingArgs, ...postArgs]; - // Validate that config and server are provided together - if ( - (options.config && !options.server) || - (!options.config && options.server) - ) { + // Validate config and server options + if (!options.config && options.server) { throw new Error( - "Both --config and --server must be provided together. If you specify one, you must specify the other.", + "--server requires --config to be specified", ); } + // If config is provided without server, try to auto-select + if (options.config && !options.server) { + const configContent = fs.readFileSync( + path.isAbsolute(options.config) + ? options.config + : path.resolve(process.cwd(), options.config), + "utf8" + ); + const parsedConfig = JSON.parse(configContent); + const servers = Object.keys(parsedConfig.mcpServers || {}); + + if (servers.includes("default-server")) { + // Use default-server if it exists + options.server = "default-server"; + } else if (servers.length === 1) { + // Use the only server if there's just one + options.server = servers[0]; + } else if (servers.length === 0) { + throw new Error("No servers found in config file"); + } else { + // Multiple servers, no default-server + throw new Error( + `Multiple servers found in config file. Please specify one with --server.\nAvailable servers: ${servers.join(", ")}` + ); + } + } + // If config file is specified, load and use the options from the file. We must merge the args // from the command line and the file together, or we will miss the method options (--method, // etc.) From 3a4f3bc5108e4922c444e3765cda6a2bd24e9d78 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 5 Aug 2025 13:36:54 +0100 Subject: [PATCH 030/281] Document transport types and default-server functionality - Add examples for stdio, sse, and streamable-http transport types - Document automatic server selection behavior - Show how to use default-server in config files --- README.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/README.md b/README.md index 6a671f1c4..932c337db 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,73 @@ Example server configuration file: } ``` +#### Transport Types in Config Files + +The inspector automatically detects the transport type from your config file. You can specify different transport types: + +**STDIO (default):** +```json +{ + "mcpServers": { + "my-stdio-server": { + "type": "stdio", + "command": "npx", + "args": ["@modelcontextprotocol/server-everything"] + } + } +} +``` + +**SSE (Server-Sent Events):** +```json +{ + "mcpServers": { + "my-sse-server": { + "type": "sse", + "url": "http://localhost:3000/sse" + } + } +} +``` + +**Streamable HTTP:** +```json +{ + "mcpServers": { + "my-http-server": { + "type": "streamable-http", + "url": "http://localhost:3000/mcp" + } + } +} +``` + +#### Default Server Selection + +You can launch the inspector without specifying a server name if your config has: + +1. **A single server** - automatically selected: +```bash +# Automatically uses "my-server" if it's the only one +npx @modelcontextprotocol/inspector --config config.json +``` + +2. **A server named "default-server"** - automatically selected: +```json +{ + "mcpServers": { + "default-server": { + "command": "npx", + "args": ["@modelcontextprotocol/server-everything"] + }, + "other-server": { + "command": "node", + "args": ["other.js"] + } + } +} +``` + > **Tip:** You can easily generate this configuration format using the **Server Entry** and **Servers File** buttons in the Inspector UI, as described in the Servers File Export section above. You can also set the initial `transport` type, `serverUrl`, `serverCommand`, and `serverArgs` via query params, for example: From 1e399b70554bbadba70ae224fb3d3c55c6b6b89d Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 5 Aug 2025 14:08:26 +0100 Subject: [PATCH 031/281] prettier --- README.md | 5 +++++ cli/scripts/cli-tests.js | 14 ++++++++++---- cli/src/cli.ts | 10 ++++------ client/bin/start.js | 7 +------ client/e2e/cli-arguments.spec.ts | 10 +++++++--- 5 files changed, 27 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 932c337db..69eada699 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,7 @@ Example server configuration file: The inspector automatically detects the transport type from your config file. You can specify different transport types: **STDIO (default):** + ```json { "mcpServers": { @@ -252,6 +253,7 @@ The inspector automatically detects the transport type from your config file. Yo ``` **SSE (Server-Sent Events):** + ```json { "mcpServers": { @@ -264,6 +266,7 @@ The inspector automatically detects the transport type from your config file. Yo ``` **Streamable HTTP:** + ```json { "mcpServers": { @@ -280,12 +283,14 @@ The inspector automatically detects the transport type from your config file. Yo You can launch the inspector without specifying a server name if your config has: 1. **A single server** - automatically selected: + ```bash # Automatically uses "my-server" if it's the only one npx @modelcontextprotocol/inspector --config config.json ``` 2. **A server named "default-server"** - automatically selected: + ```json { "mcpServers": { diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js index 87981dd83..e737756f5 100755 --- a/cli/scripts/cli-tests.js +++ b/cli/scripts/cli-tests.js @@ -785,7 +785,10 @@ async function runTests() { ); // Create config with single server for auto-selection - const singleServerConfigPath = path.join(TEMP_DIR, "single-server-config.json"); + const singleServerConfigPath = path.join( + TEMP_DIR, + "single-server-config.json", + ); fs.writeFileSync( singleServerConfigPath, JSON.stringify( @@ -803,7 +806,10 @@ async function runTests() { ); // Create config with default-server - const defaultServerConfigPath = path.join(TEMP_DIR, "default-server-config.json"); + const defaultServerConfigPath = path.join( + TEMP_DIR, + "default-server-config.json", + ); fs.writeFileSync( defaultServerConfigPath, JSON.stringify( @@ -831,11 +837,11 @@ async function runTests() { JSON.stringify( { mcpServers: { - "server1": { + server1: { command: "npx", args: ["@modelcontextprotocol/server-everything"], }, - "server2": { + server2: { command: "node", args: ["other.js"], }, diff --git a/cli/src/cli.ts b/cli/src/cli.ts index cf8662e53..09c99d514 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -222,9 +222,7 @@ function parseArgs(): Args { // Validate config and server options if (!options.config && options.server) { - throw new Error( - "--server requires --config to be specified", - ); + throw new Error("--server requires --config to be specified"); } // If config is provided without server, try to auto-select @@ -233,11 +231,11 @@ function parseArgs(): Args { path.isAbsolute(options.config) ? options.config : path.resolve(process.cwd(), options.config), - "utf8" + "utf8", ); const parsedConfig = JSON.parse(configContent); const servers = Object.keys(parsedConfig.mcpServers || {}); - + if (servers.includes("default-server")) { // Use default-server if it exists options.server = "default-server"; @@ -249,7 +247,7 @@ function parseArgs(): Args { } else { // Multiple servers, no default-server throw new Error( - `Multiple servers found in config file. Please specify one with --server.\nAvailable servers: ${servers.join(", ")}` + `Multiple servers found in config file. Please specify one with --server.\nAvailable servers: ${servers.join(", ")}`, ); } } diff --git a/client/bin/start.js b/client/bin/start.js index ddf3fac31..e0496cde0 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -13,12 +13,7 @@ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms, true)); } -function getClientUrl( - port, - authDisabled, - sessionToken, - serverPort, -) { +function getClientUrl(port, authDisabled, sessionToken, serverPort) { const host = process.env.HOST || "localhost"; const baseUrl = `http://${host}:${port}`; diff --git a/client/e2e/cli-arguments.spec.ts b/client/e2e/cli-arguments.spec.ts index 3376a6d73..a4dcdcce2 100644 --- a/client/e2e/cli-arguments.spec.ts +++ b/client/e2e/cli-arguments.spec.ts @@ -7,7 +7,9 @@ test.describe("CLI Arguments @cli", () => { page, }) => { // Simulate: npx . --transport sse --server-url http://localhost:3000/sse - await page.goto("http://localhost:6274/?transport=sse&serverUrl=http://localhost:3000/sse"); + await page.goto( + "http://localhost:6274/?transport=sse&serverUrl=http://localhost:3000/sse", + ); // Wait for the Transport Type dropdown to be visible const selectTrigger = page.getByLabel("Transport Type"); @@ -26,7 +28,9 @@ test.describe("CLI Arguments @cli", () => { page, }) => { // Simulate config with streamable-http transport - await page.goto("http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:3000/mcp"); + await page.goto( + "http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:3000/mcp", + ); // Wait for the Transport Type dropdown to be visible const selectTrigger = page.getByLabel("Transport Type"); @@ -58,4 +62,4 @@ test.describe("CLI Arguments @cli", () => { await expect(page.locator("#command-input")).toBeVisible(); await expect(page.locator("#arguments-input")).toBeVisible(); }); -}); \ No newline at end of file +}); From 195f79076ea3414965454112988a087130905993 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 5 Aug 2025 14:21:18 +0100 Subject: [PATCH 032/281] Fix CLI test failures for SSE/HTTP transport configs - Pass URL as command for SSE/HTTP configs instead of empty string - Update runCli to pass transport type flag when needed - Update tests to expect connection errors for non-existent SSE/HTTP servers --- cli/scripts/cli-tests.js | 8 ++++---- cli/src/cli.ts | 15 +++++++++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js index e737756f5..675ea0bc6 100755 --- a/cli/scripts/cli-tests.js +++ b/cli/scripts/cli-tests.js @@ -744,8 +744,8 @@ async function runTests() { "tools/list", ); - // Test 26: Config with SSE transport type (CLI mode) - await runBasicTest( + // Test 26: Config with SSE transport type (CLI mode) - expects connection error + await runErrorTest( "config_sse_type_cli", "--config", sseConfigPath, @@ -756,8 +756,8 @@ async function runTests() { "tools/list", ); - // Test 27: Config with streamable-http transport type (CLI mode) - await runBasicTest( + // Test 27: Config with streamable-http transport type (CLI mode) - expects connection error + await runErrorTest( "config_http_type_cli", "--config", httpConfigPath, diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 09c99d514..df16f0cc5 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -122,7 +122,18 @@ async function runCli(args: Args): Promise { }); try { - await spawnPromise("node", [cliPath, args.command, ...args.args], { + // Build CLI arguments + const cliArgs = [cliPath]; + + // Add transport flag if specified + if (args.transport && args.transport !== "stdio") { + cliArgs.push("--transport", args.transport); + } + + // Add command and remaining args + cliArgs.push(args.command, ...args.args); + + await spawnPromise("node", cliArgs, { env: { ...process.env, ...args.envArgs }, signal: abort.signal, echoOutput: true, @@ -268,7 +279,7 @@ function parseArgs(): Args { }; } else if (config.type === "sse" || config.type === "streamable-http") { return { - command: "", + command: config.url, args: finalArgs, envArgs: options.e || {}, cli: options.cli || false, From 7ea47c0caa123f7eb96df361d575857c263aded7 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 5 Aug 2025 19:38:44 +0100 Subject: [PATCH 033/281] Fix transport type propagation from CLI to web client - Add --transport and --server-url support to client/bin/start.js - Pass transport configuration through server to client via /config endpoint - Update client App.tsx to use defaultTransport and defaultServerUrl from server - Ensure SSE and HTTP transport configs work properly from CLI --- client/bin/start.js | 31 +++++++++++++++++++++++++++++-- client/src/App.tsx | 8 ++++++++ server/src/index.ts | 4 ++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/client/bin/start.js b/client/bin/start.js index e0496cde0..f67301de4 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -28,8 +28,15 @@ function getClientUrl(port, authDisabled, sessionToken, serverPort) { } async function startDevServer(serverOptions) { - const { SERVER_PORT, CLIENT_PORT, sessionToken, envVars, abort } = - serverOptions; + const { + SERVER_PORT, + CLIENT_PORT, + sessionToken, + envVars, + abort, + transport, + serverUrl, + } = serverOptions; const serverCommand = "npx"; const serverArgs = ["tsx", "watch", "--clear-screen=false", "src/index.ts"]; const isWindows = process.platform === "win32"; @@ -42,6 +49,8 @@ async function startDevServer(serverOptions) { CLIENT_PORT, MCP_PROXY_AUTH_TOKEN: sessionToken, MCP_ENV_VARS: JSON.stringify(envVars), + ...(transport ? { MCP_TRANSPORT: transport } : {}), + ...(serverUrl ? { MCP_SERVER_URL: serverUrl } : {}), }, signal: abort.signal, echoOutput: true, @@ -78,6 +87,8 @@ async function startProdServer(serverOptions) { abort, command, mcpServerArgs, + transport, + serverUrl, } = serverOptions; const inspectorServerPath = resolve( __dirname, @@ -95,6 +106,8 @@ async function startProdServer(serverOptions) { ...(mcpServerArgs && mcpServerArgs.length > 0 ? [`--args=${mcpServerArgs.join(" ")}`] : []), + ...(transport ? [`--transport=${transport}`] : []), + ...(serverUrl ? [`--server-url=${serverUrl}`] : []), ], { env: { @@ -208,6 +221,8 @@ async function main() { let command = null; let parsingFlags = true; let isDev = false; + let transport = null; + let serverUrl = null; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -222,6 +237,16 @@ async function main() { continue; } + if (parsingFlags && arg === "--transport" && i + 1 < args.length) { + transport = args[++i]; + continue; + } + + if (parsingFlags && arg === "--server-url" && i + 1 < args.length) { + serverUrl = args[++i]; + continue; + } + if (parsingFlags && arg === "-e" && i + 1 < args.length) { const envVar = args[++i]; const equalsIndex = envVar.indexOf("="); @@ -273,6 +298,8 @@ async function main() { abort, command, mcpServerArgs, + transport, + serverUrl, }; const result = isDev diff --git a/client/src/App.tsx b/client/src/App.tsx index 537f80bfa..d6680c35b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -455,6 +455,14 @@ const App = () => { if (data.defaultArgs) { setArgs(data.defaultArgs); } + if (data.defaultTransport) { + setTransportType( + data.defaultTransport as "stdio" | "sse" | "streamable-http", + ); + } + if (data.defaultServerUrl) { + setSseUrl(data.defaultServerUrl); + } }) .catch((error) => console.error("Error fetching default environment:", error), diff --git a/server/src/index.ts b/server/src/index.ts index 92badc2c3..34a69414a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -40,6 +40,8 @@ const { values } = parseArgs({ env: { type: "string", default: "" }, args: { type: "string", default: "" }, command: { type: "string", default: "" }, + transport: { type: "string", default: "" }, + "server-url": { type: "string", default: "" }, }, }); @@ -523,6 +525,8 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => { defaultEnvironment, defaultCommand: values.command, defaultArgs: values.args, + defaultTransport: values.transport, + defaultServerUrl: values["server-url"], }); } catch (error) { console.error("Error in /config route:", error); From b99b3d29d9b9ed210acbe1259b42ea4671524cea Mon Sep 17 00:00:00 2001 From: 2underscores Date: Mon, 28 Jul 2025 20:18:18 +1000 Subject: [PATCH 034/281] Using static client data as DCR fallback --- client/src/lib/oauth-state-machine.ts | 36 ++++++++++++++++++++------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index 5078cfe4b..758deb23f 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -88,16 +88,34 @@ export const oauthTransitions: Record = { clientMetadata.scope = scopesSupported.join(" "); } - const fullInformation = await registerClient(context.serverUrl, { - metadata, - clientMetadata, - }); + // Try DCR first, with static client as fallback + try { + const fullInformation = await registerClient(context.serverUrl, { + metadata, + clientMetadata, + }); - context.provider.saveClientInformation(fullInformation); - context.updateState({ - oauthClientInfo: fullInformation, - oauthStep: "authorization_redirect", - }); + context.provider.saveClientInformation(fullInformation); + context.updateState({ + oauthClientInfo: fullInformation, + oauthStep: "authorization_redirect", + }); + console.log({ fullInformation }); + return; + } catch (dcrError) { + console.error(dcrError); + + // DCR failed, fallback to preregistered client + const existingClientInfo = await context.provider.clientInformation(); + if (!existingClientInfo) { + console.error("Neither dynamic client registration or preregistered client information was found"); + throw dcrError; + } + context.updateState({ + oauthClientInfo: existingClientInfo, + oauthStep: "authorization_redirect", + }); + } }, }, From 6a9505673121efed5a527d5e3b405fa8bea35043 Mon Sep 17 00:00:00 2001 From: 2underscores Date: Wed, 6 Aug 2025 08:47:21 +1000 Subject: [PATCH 035/281] Refactor --- client/src/lib/oauth-state-machine.ts | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index 758deb23f..b8ecab51a 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -89,33 +89,26 @@ export const oauthTransitions: Record = { } // Try DCR first, with static client as fallback + let fullInformation; try { - const fullInformation = await registerClient(context.serverUrl, { + fullInformation = await registerClient(context.serverUrl, { metadata, clientMetadata, }); - context.provider.saveClientInformation(fullInformation); - context.updateState({ - oauthClientInfo: fullInformation, - oauthStep: "authorization_redirect", - }); - console.log({ fullInformation }); - return; } catch (dcrError) { - console.error(dcrError); - // DCR failed, fallback to preregistered client - const existingClientInfo = await context.provider.clientInformation(); - if (!existingClientInfo) { + fullInformation = await context.provider.clientInformation(); + if (!fullInformation) { console.error("Neither dynamic client registration or preregistered client information was found"); throw dcrError; } - context.updateState({ - oauthClientInfo: existingClientInfo, - oauthStep: "authorization_redirect", - }); } + + context.updateState({ + oauthClientInfo: fullInformation, + oauthStep: "authorization_redirect", + }); }, }, From 98b7e63bb032b6aae4767712af4359594271a29a Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 6 Aug 2025 19:50:23 +0100 Subject: [PATCH 036/281] Add --transport flag support to CLI - Add --transport option to command line parser - Support --transport stdio/sse/http flags - Convert 'http' to 'streamable-http' internally - Fix transport type conversion for CLI mode --- cli/src/cli.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index df16f0cc5..6c72e6a4a 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -23,6 +23,7 @@ type CliOptions = { config?: string; server?: string; cli?: boolean; + transport?: string; }; type ServerConfig = @@ -127,7 +128,9 @@ async function runCli(args: Args): Promise { // Add transport flag if specified if (args.transport && args.transport !== "stdio") { - cliArgs.push("--transport", args.transport); + // Convert streamable-http back to http for CLI mode + const cliTransport = args.transport === "streamable-http" ? "http" : args.transport; + cliArgs.push("--transport", cliTransport); } // Add command and remaining args @@ -220,7 +223,8 @@ function parseArgs(): Args { ) .option("--config ", "config file path") .option("--server ", "server name from config file") - .option("--cli", "enable CLI mode"); + .option("--cli", "enable CLI mode") + .option("--transport ", "transport type (stdio, sse, http)"); // Parse only the arguments before -- program.parse(preArgs); @@ -301,12 +305,19 @@ function parseArgs(): Args { // Otherwise use command line arguments const command = finalArgs[0] || ""; const args = finalArgs.slice(1); + + // Map "http" shorthand to "streamable-http" + let transport = options.transport; + if (transport === "http") { + transport = "streamable-http"; + } return { command, args, envArgs: options.e || {}, cli: options.cli || false, + transport: transport as "stdio" | "sse" | "streamable-http" | undefined, }; } From 80a535ee8d3206ead179e8d93a59414c8c5e65a0 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 6 Aug 2025 21:03:36 -0700 Subject: [PATCH 037/281] Add tests --- .../__tests__/DynamicJsonForm.test.tsx | 189 +++++++++++++++++- .../components/__tests__/ToolsTab.test.tsx | 167 ++++++++++++++++ 2 files changed, 355 insertions(+), 1 deletion(-) diff --git a/client/src/components/__tests__/DynamicJsonForm.test.tsx b/client/src/components/__tests__/DynamicJsonForm.test.tsx index 22813c9bc..e1ad2c4fa 100644 --- a/client/src/components/__tests__/DynamicJsonForm.test.tsx +++ b/client/src/components/__tests__/DynamicJsonForm.test.tsx @@ -1,7 +1,8 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; import { describe, it, expect, jest } from "@jest/globals"; -import DynamicJsonForm from "../DynamicJsonForm"; +import { useRef } from "react"; +import DynamicJsonForm, { DynamicJsonFormRef } from "../DynamicJsonForm"; import type { JsonSchemaType } from "@/utils/jsonUtils"; describe("DynamicJsonForm String Fields", () => { @@ -651,3 +652,189 @@ describe("DynamicJsonForm Copy JSON Functionality", () => { }); }); }); + +describe("DynamicJsonForm Validation Functionality", () => { + const renderFormWithRef = (props = {}) => { + const TestComponent = () => { + const formRef = useRef(null); + const defaultProps = { + schema: { + type: "object", + properties: { + nested: { oneOf: [{ type: "string" }, { type: "integer" }] }, + }, + } as unknown as JsonSchemaType, + value: { nested: "test value" }, + onChange: jest.fn(), + ref: formRef, + }; + + return ( +
+ + +
+ ); + }; + + 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 bc75401d4..a9e7b5fbc 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 038/281] 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 e1ad2c4fa..541da33dd 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 (
{
diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index e892a7f8b..b4dcb684d 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -878,4 +878,36 @@ describe("Sidebar Environment Variables", () => { expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig); }); }); + + describe("Command and arguments", () => { + it("should trim whitespace from command input on blur", () => { + const setCommand = jest.fn(); + renderSidebar({ command: " node server.js ", setCommand }); + + const commandInput = screen.getByLabelText("Command"); + + fireEvent.blur(commandInput); + expect(setCommand).toHaveBeenLastCalledWith("node server.js"); + }); + + it("should handle whitespace-only command input on blur", () => { + const setCommand = jest.fn(); + renderSidebar({ command: " ", setCommand }); + + const commandInput = screen.getByLabelText("Command"); + + fireEvent.blur(commandInput); + expect(setCommand).toHaveBeenLastCalledWith(""); + }); + + it("should not affect command without surrounding whitespace", () => { + const setCommand = jest.fn(); + renderSidebar({ command: "node", setCommand }); + + const commandInput = screen.getByLabelText("Command"); + + fireEvent.blur(commandInput); + expect(setCommand).toHaveBeenLastCalledWith("node"); + }); + }); }); From 9a1540c79ab376fe9ec6c4a922a37990c3d64022 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Thu, 14 Aug 2025 00:15:19 -0700 Subject: [PATCH 070/281] Trim whitespace from `command` query parameter --- server/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/index.ts b/server/src/index.ts index 34a69414a..7e0f151c7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -181,7 +181,7 @@ const createTransport = async (req: express.Request): Promise => { const transportType = query.transportType as string; if (transportType === "stdio") { - const command = query.command as string; + const command = (query.command as string).trim(); const origArgs = shellParseArgs(query.args as string) as string[]; const queryEnv = query.env ? JSON.parse(query.env as string) : {}; const env = { ...process.env, ...defaultEnvironment, ...queryEnv }; From 399e16afa4eeee8b7b4ad7a43800f78b9c4976de Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Thu, 14 Aug 2025 10:40:32 -0700 Subject: [PATCH 071/281] Re-organize Sidebar tests - Tests were mistakenly grouped in Environment Variables block - Tests follow UI order, top-to-bottom --- .../src/components/__tests__/Sidebar.test.tsx | 1140 +++++++++-------- 1 file changed, 575 insertions(+), 565 deletions(-) diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index b4dcb684d..dc7378967 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -69,655 +69,394 @@ describe("Sidebar Environment Variables", () => { ); }; - const openEnvVarsSection = () => { - const button = screen.getByTestId("env-vars-button"); - fireEvent.click(button); - }; - beforeEach(() => { jest.clearAllMocks(); jest.clearAllTimers(); }); - describe("Basic Operations", () => { - it("should add a new environment variable", () => { - const setEnv = jest.fn(); - renderSidebar({ env: {}, setEnv }); - - openEnvVarsSection(); - - const addButton = screen.getByText("Add Environment Variable"); - fireEvent.click(addButton); - - expect(setEnv).toHaveBeenCalledWith({ "": "" }); - }); - - it("should remove an environment variable", () => { - const setEnv = jest.fn(); - const initialEnv = { TEST_KEY: "test_value" }; - renderSidebar({ env: initialEnv, setEnv }); - - openEnvVarsSection(); + describe("Command and arguments", () => { + it("should trim whitespace from command input on blur", () => { + const setCommand = jest.fn(); + renderSidebar({ command: " node server.js ", setCommand }); - const removeButton = screen.getByRole("button", { name: "×" }); - fireEvent.click(removeButton); + const commandInput = screen.getByLabelText("Command"); - expect(setEnv).toHaveBeenCalledWith({}); + fireEvent.blur(commandInput); + expect(setCommand).toHaveBeenLastCalledWith("node server.js"); }); - it("should update environment variable value", () => { - const setEnv = jest.fn(); - const initialEnv = { TEST_KEY: "test_value" }; - renderSidebar({ env: initialEnv, setEnv }); - - openEnvVarsSection(); + it("should handle whitespace-only command input on blur", () => { + const setCommand = jest.fn(); + renderSidebar({ command: " ", setCommand }); - const valueInput = screen.getByDisplayValue("test_value"); - fireEvent.change(valueInput, { target: { value: "new_value" } }); + const commandInput = screen.getByLabelText("Command"); - expect(setEnv).toHaveBeenCalledWith({ TEST_KEY: "new_value" }); + fireEvent.blur(commandInput); + expect(setCommand).toHaveBeenLastCalledWith(""); }); - it("should toggle value visibility", () => { - const initialEnv = { TEST_KEY: "test_value" }; - renderSidebar({ env: initialEnv }); - - openEnvVarsSection(); - - const valueInput = screen.getByDisplayValue("test_value"); - expect(valueInput).toHaveProperty("type", "password"); + it("should not affect command without surrounding whitespace", () => { + const setCommand = jest.fn(); + renderSidebar({ command: "node", setCommand }); - const toggleButton = screen.getByRole("button", { name: /show value/i }); - fireEvent.click(toggleButton); + const commandInput = screen.getByLabelText("Command"); - expect(valueInput).toHaveProperty("type", "text"); + fireEvent.blur(commandInput); + expect(setCommand).toHaveBeenLastCalledWith("node"); }); }); - describe("Authentication", () => { - const openAuthSection = () => { - const button = screen.getByTestId("auth-button"); + describe("Environment Variables", () => { + const openEnvVarsSection = () => { + const button = screen.getByTestId("env-vars-button"); fireEvent.click(button); }; - it("should update bearer token", () => { - const setBearerToken = jest.fn(); - renderSidebar({ - bearerToken: "", - setBearerToken, - transportType: "sse", // Set transport type to SSE - }); - - openAuthSection(); + describe("Basic Operations", () => { + it("should add a new environment variable", () => { + const setEnv = jest.fn(); + renderSidebar({ env: {}, setEnv }); - const tokenInput = screen.getByTestId("bearer-token-input"); - fireEvent.change(tokenInput, { target: { value: "new_token" } }); + openEnvVarsSection(); - expect(setBearerToken).toHaveBeenCalledWith("new_token"); - }); + const addButton = screen.getByText("Add Environment Variable"); + fireEvent.click(addButton); - it("should update header name", () => { - const setHeaderName = jest.fn(); - renderSidebar({ - headerName: "Authorization", - setHeaderName, - transportType: "sse", + expect(setEnv).toHaveBeenCalledWith({ "": "" }); }); - openAuthSection(); + it("should remove an environment variable", () => { + const setEnv = jest.fn(); + const initialEnv = { TEST_KEY: "test_value" }; + renderSidebar({ env: initialEnv, setEnv }); - const headerInput = screen.getByTestId("header-input"); - fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } }); + openEnvVarsSection(); - expect(setHeaderName).toHaveBeenCalledWith("X-Custom-Auth"); - }); + const removeButton = screen.getByRole("button", { name: "×" }); + fireEvent.click(removeButton); - it("should clear bearer token", () => { - const setBearerToken = jest.fn(); - renderSidebar({ - bearerToken: "existing_token", - setBearerToken, - transportType: "sse", // Set transport type to SSE + expect(setEnv).toHaveBeenCalledWith({}); }); - openAuthSection(); + it("should update environment variable value", () => { + const setEnv = jest.fn(); + const initialEnv = { TEST_KEY: "test_value" }; + renderSidebar({ env: initialEnv, setEnv }); - const tokenInput = screen.getByTestId("bearer-token-input"); - fireEvent.change(tokenInput, { target: { value: "" } }); + openEnvVarsSection(); - expect(setBearerToken).toHaveBeenCalledWith(""); - }); + const valueInput = screen.getByDisplayValue("test_value"); + fireEvent.change(valueInput, { target: { value: "new_value" } }); - it("should properly render bearer token input", () => { - const { rerender } = renderSidebar({ - bearerToken: "existing_token", - transportType: "sse", // Set transport type to SSE + expect(setEnv).toHaveBeenCalledWith({ TEST_KEY: "new_value" }); }); - openAuthSection(); - - // Token input should be a password field - const tokenInput = screen.getByTestId("bearer-token-input"); - expect(tokenInput).toHaveProperty("type", "password"); + it("should toggle value visibility", () => { + const initialEnv = { TEST_KEY: "test_value" }; + renderSidebar({ env: initialEnv }); - // Update the token - fireEvent.change(tokenInput, { target: { value: "new_token" } }); + openEnvVarsSection(); - // Rerender with updated token - rerender( - - - , - ); + const valueInput = screen.getByDisplayValue("test_value"); + expect(valueInput).toHaveProperty("type", "password"); - // Token input should still exist after update - expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument(); - }); + const toggleButton = screen.getByRole("button", { + name: /show value/i, + }); + fireEvent.click(toggleButton); - it("should maintain token visibility state after update", () => { - const { rerender } = renderSidebar({ - bearerToken: "existing_token", - transportType: "sse", // Set transport type to SSE + expect(valueInput).toHaveProperty("type", "text"); }); + }); - openAuthSection(); - - // Token input should be a password field - const tokenInput = screen.getByTestId("bearer-token-input"); - expect(tokenInput).toHaveProperty("type", "password"); - - // Update the token - fireEvent.change(tokenInput, { target: { value: "new_token" } }); + describe("Key Editing", () => { + it("should maintain order when editing first key", () => { + const setEnv = jest.fn(); + const initialEnv = { + FIRST_KEY: "first_value", + SECOND_KEY: "second_value", + THIRD_KEY: "third_value", + }; + renderSidebar({ env: initialEnv, setEnv }); - // Rerender with updated token - rerender( - - - , - ); + openEnvVarsSection(); - // Token input should still exist after update - expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument(); - }); + const firstKeyInput = screen.getByDisplayValue("FIRST_KEY"); + fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } }); - it("should maintain header name when toggling auth section", () => { - renderSidebar({ - headerName: "X-API-Key", - transportType: "sse", + expect(setEnv).toHaveBeenCalledWith({ + NEW_FIRST_KEY: "first_value", + SECOND_KEY: "second_value", + THIRD_KEY: "third_value", + }); }); - // Open auth section - openAuthSection(); - - // Verify header name is displayed - const headerInput = screen.getByTestId("header-input"); - expect(headerInput).toHaveValue("X-API-Key"); - - // Close auth section - const authButton = screen.getByTestId("auth-button"); - fireEvent.click(authButton); + it("should maintain order when editing middle key", () => { + const setEnv = jest.fn(); + const initialEnv = { + FIRST_KEY: "first_value", + SECOND_KEY: "second_value", + THIRD_KEY: "third_value", + }; + renderSidebar({ env: initialEnv, setEnv }); - // Reopen auth section - fireEvent.click(authButton); + openEnvVarsSection(); - // Verify header name is still preserved - expect(screen.getByTestId("header-input")).toHaveValue("X-API-Key"); - }); + const middleKeyInput = screen.getByDisplayValue("SECOND_KEY"); + fireEvent.change(middleKeyInput, { + target: { value: "NEW_SECOND_KEY" }, + }); - it("should display default header name when not specified", () => { - renderSidebar({ - headerName: undefined, - transportType: "sse", + expect(setEnv).toHaveBeenCalledWith({ + FIRST_KEY: "first_value", + NEW_SECOND_KEY: "second_value", + THIRD_KEY: "third_value", + }); }); - openAuthSection(); - - const headerInput = screen.getByTestId("header-input"); - expect(headerInput).toHaveAttribute("placeholder", "Authorization"); - }); - }); - - describe("Key Editing", () => { - it("should maintain order when editing first key", () => { - const setEnv = jest.fn(); - const initialEnv = { - FIRST_KEY: "first_value", - SECOND_KEY: "second_value", - THIRD_KEY: "third_value", - }; - renderSidebar({ env: initialEnv, setEnv }); + it("should maintain order when editing last key", () => { + const setEnv = jest.fn(); + const initialEnv = { + FIRST_KEY: "first_value", + SECOND_KEY: "second_value", + THIRD_KEY: "third_value", + }; + renderSidebar({ env: initialEnv, setEnv }); - openEnvVarsSection(); + openEnvVarsSection(); - const firstKeyInput = screen.getByDisplayValue("FIRST_KEY"); - fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } }); + const lastKeyInput = screen.getByDisplayValue("THIRD_KEY"); + fireEvent.change(lastKeyInput, { target: { value: "NEW_THIRD_KEY" } }); - expect(setEnv).toHaveBeenCalledWith({ - NEW_FIRST_KEY: "first_value", - SECOND_KEY: "second_value", - THIRD_KEY: "third_value", + expect(setEnv).toHaveBeenCalledWith({ + FIRST_KEY: "first_value", + SECOND_KEY: "second_value", + NEW_THIRD_KEY: "third_value", + }); }); - }); - - it("should maintain order when editing middle key", () => { - const setEnv = jest.fn(); - const initialEnv = { - FIRST_KEY: "first_value", - SECOND_KEY: "second_value", - THIRD_KEY: "third_value", - }; - renderSidebar({ env: initialEnv, setEnv }); - openEnvVarsSection(); + it("should maintain order during key editing", () => { + const setEnv = jest.fn(); + const initialEnv = { + KEY1: "value1", + KEY2: "value2", + }; + renderSidebar({ env: initialEnv, setEnv }); + + openEnvVarsSection(); + + // Type "NEW_" one character at a time + const key1Input = screen.getByDisplayValue("KEY1"); + "NEW_".split("").forEach((char) => { + fireEvent.change(key1Input, { + target: { value: char + "KEY1".slice(1) }, + }); + }); - const middleKeyInput = screen.getByDisplayValue("SECOND_KEY"); - fireEvent.change(middleKeyInput, { target: { value: "NEW_SECOND_KEY" } }); + // Verify the last setEnv call maintains the order + const lastCall = setEnv.mock.calls[ + setEnv.mock.calls.length - 1 + ][0] as Record; + const entries = Object.entries(lastCall); - expect(setEnv).toHaveBeenCalledWith({ - FIRST_KEY: "first_value", - NEW_SECOND_KEY: "second_value", - THIRD_KEY: "third_value", + // The values should stay with their original keys + expect(entries[0][1]).toBe("value1"); // First entry should still have value1 + expect(entries[1][1]).toBe("value2"); // Second entry should still have value2 }); }); - it("should maintain order when editing last key", () => { - const setEnv = jest.fn(); - const initialEnv = { - FIRST_KEY: "first_value", - SECOND_KEY: "second_value", - THIRD_KEY: "third_value", - }; - renderSidebar({ env: initialEnv, setEnv }); + describe("Multiple Operations", () => { + it("should maintain state after multiple key edits", () => { + const setEnv = jest.fn(); + const initialEnv = { + FIRST_KEY: "first_value", + SECOND_KEY: "second_value", + }; + const { rerender } = renderSidebar({ env: initialEnv, setEnv }); - openEnvVarsSection(); + openEnvVarsSection(); - const lastKeyInput = screen.getByDisplayValue("THIRD_KEY"); - fireEvent.change(lastKeyInput, { target: { value: "NEW_THIRD_KEY" } }); + // First key edit + const firstKeyInput = screen.getByDisplayValue("FIRST_KEY"); + fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } }); - expect(setEnv).toHaveBeenCalledWith({ - FIRST_KEY: "first_value", - SECOND_KEY: "second_value", - NEW_THIRD_KEY: "third_value", - }); - }); + // Get the updated env from the first setEnv call + const updatedEnv = setEnv.mock.calls[0][0] as Record; - it("should maintain order during key editing", () => { - const setEnv = jest.fn(); - const initialEnv = { - KEY1: "value1", - KEY2: "value2", - }; - renderSidebar({ env: initialEnv, setEnv }); + // Rerender with the updated env + rerender( + + + , + ); - openEnvVarsSection(); + // Second key edit + const secondKeyInput = screen.getByDisplayValue("SECOND_KEY"); + fireEvent.change(secondKeyInput, { + target: { value: "NEW_SECOND_KEY" }, + }); - // Type "NEW_" one character at a time - const key1Input = screen.getByDisplayValue("KEY1"); - "NEW_".split("").forEach((char) => { - fireEvent.change(key1Input, { - target: { value: char + "KEY1".slice(1) }, + // Verify the final state matches what we expect + expect(setEnv).toHaveBeenLastCalledWith({ + NEW_FIRST_KEY: "first_value", + NEW_SECOND_KEY: "second_value", }); }); - // Verify the last setEnv call maintains the order - const lastCall = setEnv.mock.calls[ - setEnv.mock.calls.length - 1 - ][0] as Record; - const entries = Object.entries(lastCall); - - // The values should stay with their original keys - expect(entries[0][1]).toBe("value1"); // First entry should still have value1 - expect(entries[1][1]).toBe("value2"); // Second entry should still have value2 - }); - }); - - describe("Multiple Operations", () => { - it("should maintain state after multiple key edits", () => { - const setEnv = jest.fn(); - const initialEnv = { - FIRST_KEY: "first_value", - SECOND_KEY: "second_value", - }; - const { rerender } = renderSidebar({ env: initialEnv, setEnv }); + it("should maintain visibility state after key edit", () => { + const initialEnv = { TEST_KEY: "test_value" }; + const { rerender } = renderSidebar({ env: initialEnv }); - openEnvVarsSection(); + openEnvVarsSection(); - // First key edit - const firstKeyInput = screen.getByDisplayValue("FIRST_KEY"); - fireEvent.change(firstKeyInput, { target: { value: "NEW_FIRST_KEY" } }); + // Show the value + const toggleButton = screen.getByRole("button", { + name: /show value/i, + }); + fireEvent.click(toggleButton); - // Get the updated env from the first setEnv call - const updatedEnv = setEnv.mock.calls[0][0] as Record; + const valueInput = screen.getByDisplayValue("test_value"); + expect(valueInput).toHaveProperty("type", "text"); - // Rerender with the updated env - rerender( - - - , - ); + // Edit the key + const keyInput = screen.getByDisplayValue("TEST_KEY"); + fireEvent.change(keyInput, { target: { value: "NEW_KEY" } }); - // Second key edit - const secondKeyInput = screen.getByDisplayValue("SECOND_KEY"); - fireEvent.change(secondKeyInput, { target: { value: "NEW_SECOND_KEY" } }); + // Rerender with updated env + rerender( + + + , + ); - // Verify the final state matches what we expect - expect(setEnv).toHaveBeenLastCalledWith({ - NEW_FIRST_KEY: "first_value", - NEW_SECOND_KEY: "second_value", + // Value should still be visible + const updatedValueInput = screen.getByDisplayValue("test_value"); + expect(updatedValueInput).toHaveProperty("type", "text"); }); }); - it("should maintain visibility state after key edit", () => { - const initialEnv = { TEST_KEY: "test_value" }; - const { rerender } = renderSidebar({ env: initialEnv }); + describe("Edge Cases", () => { + it("should handle empty key", () => { + const setEnv = jest.fn(); + const initialEnv = { TEST_KEY: "test_value" }; + renderSidebar({ env: initialEnv, setEnv }); - openEnvVarsSection(); + openEnvVarsSection(); - // Show the value - const toggleButton = screen.getByRole("button", { name: /show value/i }); - fireEvent.click(toggleButton); + const keyInput = screen.getByDisplayValue("TEST_KEY"); + fireEvent.change(keyInput, { target: { value: "" } }); - const valueInput = screen.getByDisplayValue("test_value"); - expect(valueInput).toHaveProperty("type", "text"); + expect(setEnv).toHaveBeenCalledWith({ "": "test_value" }); + }); - // Edit the key - const keyInput = screen.getByDisplayValue("TEST_KEY"); - fireEvent.change(keyInput, { target: { value: "NEW_KEY" } }); + it("should handle special characters in key", () => { + const setEnv = jest.fn(); + const initialEnv = { TEST_KEY: "test_value" }; + renderSidebar({ env: initialEnv, setEnv }); - // Rerender with updated env - rerender( - - - , - ); + openEnvVarsSection(); - // Value should still be visible - const updatedValueInput = screen.getByDisplayValue("test_value"); - expect(updatedValueInput).toHaveProperty("type", "text"); - }); - }); + const keyInput = screen.getByDisplayValue("TEST_KEY"); + fireEvent.change(keyInput, { target: { value: "TEST-KEY@123" } }); + + expect(setEnv).toHaveBeenCalledWith({ "TEST-KEY@123": "test_value" }); + }); - describe("Edge Cases", () => { - it("should handle empty key", () => { - const setEnv = jest.fn(); - const initialEnv = { TEST_KEY: "test_value" }; - renderSidebar({ env: initialEnv, setEnv }); + it("should handle unicode characters", () => { + const setEnv = jest.fn(); + const initialEnv = { TEST_KEY: "test_value" }; + renderSidebar({ env: initialEnv, setEnv }); - openEnvVarsSection(); + openEnvVarsSection(); - const keyInput = screen.getByDisplayValue("TEST_KEY"); - fireEvent.change(keyInput, { target: { value: "" } }); + const keyInput = screen.getByDisplayValue("TEST_KEY"); + fireEvent.change(keyInput, { target: { value: "TEST_🔑" } }); - expect(setEnv).toHaveBeenCalledWith({ "": "test_value" }); - }); + expect(setEnv).toHaveBeenCalledWith({ "TEST_🔑": "test_value" }); + }); - it("should handle special characters in key", () => { - const setEnv = jest.fn(); - const initialEnv = { TEST_KEY: "test_value" }; - renderSidebar({ env: initialEnv, setEnv }); + it("should handle very long key names", () => { + const setEnv = jest.fn(); + const initialEnv = { TEST_KEY: "test_value" }; + renderSidebar({ env: initialEnv, setEnv }); - openEnvVarsSection(); + openEnvVarsSection(); - const keyInput = screen.getByDisplayValue("TEST_KEY"); - fireEvent.change(keyInput, { target: { value: "TEST-KEY@123" } }); + const keyInput = screen.getByDisplayValue("TEST_KEY"); + const longKey = "A".repeat(100); + fireEvent.change(keyInput, { target: { value: longKey } }); - expect(setEnv).toHaveBeenCalledWith({ "TEST-KEY@123": "test_value" }); + expect(setEnv).toHaveBeenCalledWith({ [longKey]: "test_value" }); + }); }); + }); - it("should handle unicode characters", () => { - const setEnv = jest.fn(); - const initialEnv = { TEST_KEY: "test_value" }; - renderSidebar({ env: initialEnv, setEnv }); - - openEnvVarsSection(); + describe("Copy Configuration Features", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + }); - const keyInput = screen.getByDisplayValue("TEST_KEY"); - fireEvent.change(keyInput, { target: { value: "TEST_🔑" } }); + const getCopyButtons = () => { + return { + serverEntry: screen.getByRole("button", { name: /server entry/i }), + serversFile: screen.getByRole("button", { name: /servers file/i }), + }; + }; - expect(setEnv).toHaveBeenCalledWith({ "TEST_🔑": "test_value" }); + it("should render both copy buttons for all transport types", () => { + ["stdio", "sse", "streamable-http"].forEach((transportType) => { + renderSidebar({ transportType }); + // There should be exactly one Server Entry and one Servers File button per render + const serverEntryButtons = screen.getAllByRole("button", { + name: /server entry/i, + }); + const serversFileButtons = screen.getAllByRole("button", { + name: /servers file/i, + }); + expect(serverEntryButtons).toHaveLength(1); + expect(serversFileButtons).toHaveLength(1); + // Clean up DOM for next iteration + // (Testing Library's render does not auto-unmount in a loop) + document.body.innerHTML = ""; + }); }); - it("should handle very long key names", () => { - const setEnv = jest.fn(); - const initialEnv = { TEST_KEY: "test_value" }; - renderSidebar({ env: initialEnv, setEnv }); + it("should copy server entry configuration to clipboard for STDIO transport", async () => { + const command = "node"; + const args = "--inspect server.js"; + const env = { API_KEY: "test-key", DEBUG: "true" }; - openEnvVarsSection(); + renderSidebar({ + transportType: "stdio", + command, + args, + env, + }); - const keyInput = screen.getByDisplayValue("TEST_KEY"); - const longKey = "A".repeat(100); - fireEvent.change(keyInput, { target: { value: longKey } }); + await act(async () => { + const { serverEntry } = getCopyButtons(); + fireEvent.click(serverEntry); + jest.runAllTimers(); + }); - expect(setEnv).toHaveBeenCalledWith({ [longKey]: "test_value" }); + expect(mockClipboardWrite).toHaveBeenCalledTimes(1); + const expectedConfig = JSON.stringify( + { + command, + args: ["--inspect", "server.js"], + env, + }, + null, + 4, + ); + expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig); }); - }); - - describe("Configuration Operations", () => { - const openConfigSection = () => { - const button = screen.getByTestId("config-button"); - fireEvent.click(button); - }; - it("should update MCP server request timeout", () => { - const setConfig = jest.fn(); - renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); - - openConfigSection(); - - const timeoutInput = screen.getByTestId( - "MCP_SERVER_REQUEST_TIMEOUT-input", - ); - fireEvent.change(timeoutInput, { target: { value: "5000" } }); - - expect(setConfig).toHaveBeenCalledWith( - expect.objectContaining({ - MCP_SERVER_REQUEST_TIMEOUT: { - label: "Request Timeout", - description: "Timeout for requests to the MCP server (ms)", - value: 5000, - is_session_item: false, - }, - }), - ); - }); - - it("should update MCP server proxy address", () => { - const setConfig = jest.fn(); - renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); - - openConfigSection(); - - const proxyAddressInput = screen.getByTestId( - "MCP_PROXY_FULL_ADDRESS-input", - ); - fireEvent.change(proxyAddressInput, { - target: { value: "http://localhost:8080" }, - }); - - expect(setConfig).toHaveBeenCalledWith( - expect.objectContaining({ - MCP_PROXY_FULL_ADDRESS: { - label: "Inspector Proxy Address", - description: - "Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577", - value: "http://localhost:8080", - is_session_item: false, - }, - }), - ); - }); - - it("should update max total timeout", () => { - const setConfig = jest.fn(); - renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); - - openConfigSection(); - - const maxTotalTimeoutInput = screen.getByTestId( - "MCP_REQUEST_MAX_TOTAL_TIMEOUT-input", - ); - fireEvent.change(maxTotalTimeoutInput, { - target: { value: "10000" }, - }); - - expect(setConfig).toHaveBeenCalledWith( - expect.objectContaining({ - MCP_REQUEST_MAX_TOTAL_TIMEOUT: { - label: "Maximum Total Timeout", - description: - "Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)", - value: 10000, - is_session_item: false, - }, - }), - ); - }); - - it("should handle invalid timeout values entered by user", () => { - const setConfig = jest.fn(); - renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); - - openConfigSection(); - - const timeoutInput = screen.getByTestId( - "MCP_SERVER_REQUEST_TIMEOUT-input", - ); - fireEvent.change(timeoutInput, { target: { value: "abc1" } }); - - expect(setConfig).toHaveBeenCalledWith( - expect.objectContaining({ - MCP_SERVER_REQUEST_TIMEOUT: { - label: "Request Timeout", - description: "Timeout for requests to the MCP server (ms)", - value: 0, - is_session_item: false, - }, - }), - ); - }); - - it("should maintain configuration state after multiple updates", () => { - const setConfig = jest.fn(); - const { rerender } = renderSidebar({ - config: DEFAULT_INSPECTOR_CONFIG, - setConfig, - }); - - openConfigSection(); - // First update - const timeoutInput = screen.getByTestId( - "MCP_SERVER_REQUEST_TIMEOUT-input", - ); - fireEvent.change(timeoutInput, { target: { value: "5000" } }); - - // Get the updated config from the first setConfig call - const updatedConfig = setConfig.mock.calls[0][0] as InspectorConfig; - - // Rerender with the updated config - rerender( - - - , - ); - - // Second update - const updatedTimeoutInput = screen.getByTestId( - "MCP_SERVER_REQUEST_TIMEOUT-input", - ); - fireEvent.change(updatedTimeoutInput, { target: { value: "3000" } }); - - // Verify the final state matches what we expect - expect(setConfig).toHaveBeenLastCalledWith( - expect.objectContaining({ - MCP_SERVER_REQUEST_TIMEOUT: { - label: "Request Timeout", - description: "Timeout for requests to the MCP server (ms)", - value: 3000, - is_session_item: false, - }, - }), - ); - }); - }); - - describe("Copy Configuration Features", () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.clearAllTimers(); - }); - - const getCopyButtons = () => { - return { - serverEntry: screen.getByRole("button", { name: /server entry/i }), - serversFile: screen.getByRole("button", { name: /servers file/i }), - }; - }; - - it("should render both copy buttons for all transport types", () => { - ["stdio", "sse", "streamable-http"].forEach((transportType) => { - renderSidebar({ transportType }); - // There should be exactly one Server Entry and one Servers File button per render - const serverEntryButtons = screen.getAllByRole("button", { - name: /server entry/i, - }); - const serversFileButtons = screen.getAllByRole("button", { - name: /servers file/i, - }); - expect(serverEntryButtons).toHaveLength(1); - expect(serversFileButtons).toHaveLength(1); - // Clean up DOM for next iteration - // (Testing Library's render does not auto-unmount in a loop) - document.body.innerHTML = ""; - }); - }); - - it("should copy server entry configuration to clipboard for STDIO transport", async () => { - const command = "node"; - const args = "--inspect server.js"; - const env = { API_KEY: "test-key", DEBUG: "true" }; - - renderSidebar({ - transportType: "stdio", - command, - args, - env, - }); - - await act(async () => { - const { serverEntry } = getCopyButtons(); - fireEvent.click(serverEntry); - jest.runAllTimers(); - }); - - expect(mockClipboardWrite).toHaveBeenCalledTimes(1); - const expectedConfig = JSON.stringify( - { - command, - args: ["--inspect", "server.js"], - env, - }, - null, - 4, - ); - expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig); - }); - - it("should copy servers file configuration to clipboard for STDIO transport", async () => { - const command = "node"; - const args = "--inspect server.js"; - const env = { API_KEY: "test-key", DEBUG: "true" }; + it("should copy servers file configuration to clipboard for STDIO transport", async () => { + const command = "node"; + const args = "--inspect server.js"; + const env = { API_KEY: "test-key", DEBUG: "true" }; renderSidebar({ transportType: "stdio", @@ -879,35 +618,306 @@ describe("Sidebar Environment Variables", () => { }); }); - describe("Command and arguments", () => { - it("should trim whitespace from command input on blur", () => { - const setCommand = jest.fn(); - renderSidebar({ command: " node server.js ", setCommand }); - - const commandInput = screen.getByLabelText("Command"); + describe("Authentication", () => { + const openAuthSection = () => { + const button = screen.getByTestId("auth-button"); + fireEvent.click(button); + }; - fireEvent.blur(commandInput); - expect(setCommand).toHaveBeenLastCalledWith("node server.js"); - }); + it("should update bearer token", () => { + const setBearerToken = jest.fn(); + renderSidebar({ + bearerToken: "", + setBearerToken, + transportType: "sse", // Set transport type to SSE + }); - it("should handle whitespace-only command input on blur", () => { - const setCommand = jest.fn(); - renderSidebar({ command: " ", setCommand }); + openAuthSection(); - const commandInput = screen.getByLabelText("Command"); + const tokenInput = screen.getByTestId("bearer-token-input"); + fireEvent.change(tokenInput, { target: { value: "new_token" } }); - fireEvent.blur(commandInput); - expect(setCommand).toHaveBeenLastCalledWith(""); + expect(setBearerToken).toHaveBeenCalledWith("new_token"); }); - it("should not affect command without surrounding whitespace", () => { - const setCommand = jest.fn(); - renderSidebar({ command: "node", setCommand }); + it("should update header name", () => { + const setHeaderName = jest.fn(); + renderSidebar({ + headerName: "Authorization", + setHeaderName, + transportType: "sse", + }); - const commandInput = screen.getByLabelText("Command"); + openAuthSection(); - fireEvent.blur(commandInput); - expect(setCommand).toHaveBeenLastCalledWith("node"); + const headerInput = screen.getByTestId("header-input"); + fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } }); + + expect(setHeaderName).toHaveBeenCalledWith("X-Custom-Auth"); + }); + + it("should clear bearer token", () => { + const setBearerToken = jest.fn(); + renderSidebar({ + bearerToken: "existing_token", + setBearerToken, + transportType: "sse", // Set transport type to SSE + }); + + openAuthSection(); + + const tokenInput = screen.getByTestId("bearer-token-input"); + fireEvent.change(tokenInput, { target: { value: "" } }); + + expect(setBearerToken).toHaveBeenCalledWith(""); + }); + + it("should properly render bearer token input", () => { + const { rerender } = renderSidebar({ + bearerToken: "existing_token", + transportType: "sse", // Set transport type to SSE + }); + + openAuthSection(); + + // Token input should be a password field + const tokenInput = screen.getByTestId("bearer-token-input"); + expect(tokenInput).toHaveProperty("type", "password"); + + // Update the token + fireEvent.change(tokenInput, { target: { value: "new_token" } }); + + // Rerender with updated token + rerender( + + + , + ); + + // Token input should still exist after update + expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument(); + }); + + it("should maintain token visibility state after update", () => { + const { rerender } = renderSidebar({ + bearerToken: "existing_token", + transportType: "sse", // Set transport type to SSE + }); + + openAuthSection(); + + // Token input should be a password field + const tokenInput = screen.getByTestId("bearer-token-input"); + expect(tokenInput).toHaveProperty("type", "password"); + + // Update the token + fireEvent.change(tokenInput, { target: { value: "new_token" } }); + + // Rerender with updated token + rerender( + + + , + ); + + // Token input should still exist after update + expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument(); + }); + + it("should maintain header name when toggling auth section", () => { + renderSidebar({ + headerName: "X-API-Key", + transportType: "sse", + }); + + // Open auth section + openAuthSection(); + + // Verify header name is displayed + const headerInput = screen.getByTestId("header-input"); + expect(headerInput).toHaveValue("X-API-Key"); + + // Close auth section + const authButton = screen.getByTestId("auth-button"); + fireEvent.click(authButton); + + // Reopen auth section + fireEvent.click(authButton); + + // Verify header name is still preserved + expect(screen.getByTestId("header-input")).toHaveValue("X-API-Key"); + }); + + it("should display default header name when not specified", () => { + renderSidebar({ + headerName: undefined, + transportType: "sse", + }); + + openAuthSection(); + + const headerInput = screen.getByTestId("header-input"); + expect(headerInput).toHaveAttribute("placeholder", "Authorization"); + }); + }); + + describe("Configuration Operations", () => { + const openConfigSection = () => { + const button = screen.getByTestId("config-button"); + fireEvent.click(button); + }; + + it("should update MCP server request timeout", () => { + const setConfig = jest.fn(); + renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); + + openConfigSection(); + + const timeoutInput = screen.getByTestId( + "MCP_SERVER_REQUEST_TIMEOUT-input", + ); + fireEvent.change(timeoutInput, { target: { value: "5000" } }); + + expect(setConfig).toHaveBeenCalledWith( + expect.objectContaining({ + MCP_SERVER_REQUEST_TIMEOUT: { + label: "Request Timeout", + description: "Timeout for requests to the MCP server (ms)", + value: 5000, + is_session_item: false, + }, + }), + ); + }); + + it("should update MCP server proxy address", () => { + const setConfig = jest.fn(); + renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); + + openConfigSection(); + + const proxyAddressInput = screen.getByTestId( + "MCP_PROXY_FULL_ADDRESS-input", + ); + fireEvent.change(proxyAddressInput, { + target: { value: "http://localhost:8080" }, + }); + + expect(setConfig).toHaveBeenCalledWith( + expect.objectContaining({ + MCP_PROXY_FULL_ADDRESS: { + label: "Inspector Proxy Address", + description: + "Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577", + value: "http://localhost:8080", + is_session_item: false, + }, + }), + ); + }); + + it("should update max total timeout", () => { + const setConfig = jest.fn(); + renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); + + openConfigSection(); + + const maxTotalTimeoutInput = screen.getByTestId( + "MCP_REQUEST_MAX_TOTAL_TIMEOUT-input", + ); + fireEvent.change(maxTotalTimeoutInput, { + target: { value: "10000" }, + }); + + expect(setConfig).toHaveBeenCalledWith( + expect.objectContaining({ + MCP_REQUEST_MAX_TOTAL_TIMEOUT: { + label: "Maximum Total Timeout", + description: + "Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)", + value: 10000, + is_session_item: false, + }, + }), + ); + }); + + it("should handle invalid timeout values entered by user", () => { + const setConfig = jest.fn(); + renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig }); + + openConfigSection(); + + const timeoutInput = screen.getByTestId( + "MCP_SERVER_REQUEST_TIMEOUT-input", + ); + fireEvent.change(timeoutInput, { target: { value: "abc1" } }); + + expect(setConfig).toHaveBeenCalledWith( + expect.objectContaining({ + MCP_SERVER_REQUEST_TIMEOUT: { + label: "Request Timeout", + description: "Timeout for requests to the MCP server (ms)", + value: 0, + is_session_item: false, + }, + }), + ); + }); + + it("should maintain configuration state after multiple updates", () => { + const setConfig = jest.fn(); + const { rerender } = renderSidebar({ + config: DEFAULT_INSPECTOR_CONFIG, + setConfig, + }); + + openConfigSection(); + // First update + const timeoutInput = screen.getByTestId( + "MCP_SERVER_REQUEST_TIMEOUT-input", + ); + fireEvent.change(timeoutInput, { target: { value: "5000" } }); + + // Get the updated config from the first setConfig call + const updatedConfig = setConfig.mock.calls[0][0] as InspectorConfig; + + // Rerender with the updated config + rerender( + + + , + ); + + // Second update + const updatedTimeoutInput = screen.getByTestId( + "MCP_SERVER_REQUEST_TIMEOUT-input", + ); + fireEvent.change(updatedTimeoutInput, { target: { value: "3000" } }); + + // Verify the final state matches what we expect + expect(setConfig).toHaveBeenLastCalledWith( + expect.objectContaining({ + MCP_SERVER_REQUEST_TIMEOUT: { + label: "Request Timeout", + description: "Timeout for requests to the MCP server (ms)", + value: 3000, + is_session_item: false, + }, + }), + ); }); }); }); From 34938b72d30d28bf0e656efa0a10a2f1dfd2537a Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Thu, 14 Aug 2025 10:49:04 -0700 Subject: [PATCH 072/281] Tidy test descriptions and titles --- client/src/components/__tests__/Sidebar.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index dc7378967..cb43663ac 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -31,7 +31,7 @@ Object.defineProperty(navigator, "clipboard", { // Setup fake timers jest.useFakeTimers(); -describe("Sidebar Environment Variables", () => { +describe("Sidebar", () => { const defaultProps = { connectionStatus: "disconnected" as const, transportType: "stdio" as const, @@ -362,7 +362,7 @@ describe("Sidebar Environment Variables", () => { expect(setEnv).toHaveBeenCalledWith({ "TEST-KEY@123": "test_value" }); }); - it("should handle unicode characters", () => { + it("should handle unicode characters in key", () => { const setEnv = jest.fn(); const initialEnv = { TEST_KEY: "test_value" }; renderSidebar({ env: initialEnv, setEnv }); @@ -375,7 +375,7 @@ describe("Sidebar Environment Variables", () => { expect(setEnv).toHaveBeenCalledWith({ "TEST_🔑": "test_value" }); }); - it("should handle very long key names", () => { + it("should handle a very long key name", () => { const setEnv = jest.fn(); const initialEnv = { TEST_KEY: "test_value" }; renderSidebar({ env: initialEnv, setEnv }); @@ -391,7 +391,7 @@ describe("Sidebar Environment Variables", () => { }); }); - describe("Copy Configuration Features", () => { + describe("Copy Server Features", () => { beforeEach(() => { jest.clearAllMocks(); jest.clearAllTimers(); @@ -769,7 +769,7 @@ describe("Sidebar Environment Variables", () => { }); }); - describe("Configuration Operations", () => { + describe("Configuration", () => { const openConfigSection = () => { const button = screen.getByTestId("config-button"); fireEvent.click(button); From 410f792d29af58ea77e929fe449db2d90c644f46 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Thu, 14 Aug 2025 10:50:29 -0700 Subject: [PATCH 073/281] Remove redundant mock and timer clear --- client/src/components/__tests__/Sidebar.test.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx index cb43663ac..8f6937170 100644 --- a/client/src/components/__tests__/Sidebar.test.tsx +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -392,11 +392,6 @@ describe("Sidebar", () => { }); describe("Copy Server Features", () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.clearAllTimers(); - }); - const getCopyButtons = () => { return { serverEntry: screen.getByRole("button", { name: /server entry/i }), From 0831e812bb49c7d06f5a243db4b5e80708747332 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 15 Aug 2025 14:44:51 -0400 Subject: [PATCH 074/281] Align Quick OAuth Flow with Guided OAuth Flow behavior - In oauth-state-machine.ts - in token_request transition - ensure resource is URL type, not string - this fixes Quick flow which was sending string, while guided was using proper URL type and working --- client/src/lib/oauth-state-machine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index 4a2c0f473..a5ae9d40d 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -177,7 +177,7 @@ export const oauthTransitions: Record = { authorizationCode: context.state.authorizationCode, codeVerifier, redirectUri: context.provider.redirectUrl, - resource: context.state.resource ?? undefined, + resource: context.state.resource ? (context.state.resource instanceof URL ? context.state.resource : new URL(context.state.resource)) : undefined, }); context.provider.saveTokens(tokens); From 94f44c7b57e5070fdf7fb9f15857685f02be2751 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 15 Aug 2025 17:19:27 -0400 Subject: [PATCH 075/281] Prettier --- client/src/lib/oauth-state-machine.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index a5ae9d40d..914658535 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -177,7 +177,11 @@ export const oauthTransitions: Record = { authorizationCode: context.state.authorizationCode, codeVerifier, redirectUri: context.provider.redirectUrl, - resource: context.state.resource ? (context.state.resource instanceof URL ? context.state.resource : new URL(context.state.resource)) : undefined, + resource: context.state.resource + ? context.state.resource instanceof URL + ? context.state.resource + : new URL(context.state.resource) + : undefined, }); context.provider.saveTokens(tokens); From 5323d4d8f4aef1c68745f8491d4dd58e79ba6c7e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 15 Aug 2025 18:40:21 -0400 Subject: [PATCH 076/281] Fix connect button behavior for Oauth by making sure that scope is included in client metadata when client is registered. - In auth.ts - in InspectorOAuthClientProvider constructor - add optional scope param - set instance var this.scope to scope param - in clientMetadata getter - add scope param set to this.scope or "" - in useConnection.ts - in handleAuthError handler - move instantiation of serverAuthProvider until after scope has been determined - pass scope to InspectorOAuthClientProvider constructor --- client/src/lib/auth.ts | 8 +++++++- client/src/lib/hooks/useConnection.ts | 3 +-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 997073878..78d52a59e 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -102,10 +102,15 @@ export const clearClientInformationFromSessionStorage = ({ }; export class InspectorOAuthClientProvider implements OAuthClientProvider { - constructor(protected serverUrl: string) { + constructor( + protected serverUrl: string, + scope?: string, + ) { + this.scope = scope; // Save the server URL to session storage sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl); } + scope: string | undefined; get redirectUrl() { return window.location.origin + "/oauth/callback"; @@ -119,6 +124,7 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { response_types: ["code"], client_name: "MCP Inspector", client_uri: "https://github.com/modelcontextprotocol/inspector", + scope: this.scope ?? "", }; } diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index e60081974..fc3e214f1 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -319,8 +319,6 @@ export function useConnection({ const handleAuthError = async (error: unknown) => { if (is401Error(error)) { - const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl); - let scope = oauthScope?.trim(); if (!scope) { // Only discover resource metadata when we need to discover scopes @@ -334,6 +332,7 @@ export function useConnection({ } scope = await discoverScopes(sseUrl, resourceMetadata); } + const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl, scope); const result = await auth(serverAuthProvider, { serverUrl: sseUrl, From 3adb1dd5c9790c1092978b813af7dabfa33f6c7f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 15 Aug 2025 18:55:15 -0400 Subject: [PATCH 077/281] Prettier --- client/src/lib/hooks/useConnection.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index fc3e214f1..d3690f31e 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -332,7 +332,10 @@ export function useConnection({ } scope = await discoverScopes(sseUrl, resourceMetadata); } - const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl, scope); + const serverAuthProvider = new InspectorOAuthClientProvider( + sseUrl, + scope, + ); const result = await auth(serverAuthProvider, { serverUrl: sseUrl, From fbce75aba0eefcb4033176b19fc322dfc91d9702 Mon Sep 17 00:00:00 2001 From: Cameron Roberts Date: Sun, 17 Aug 2025 23:10:35 +0100 Subject: [PATCH 078/281] feat: Add search functionality to the ListPane component which exposes it for - Resources - Prompts - Tools --- client/src/components/ListPane.tsx | 137 ++++++++++++++++++++++------- 1 file changed, 104 insertions(+), 33 deletions(-) diff --git a/client/src/components/ListPane.tsx b/client/src/components/ListPane.tsx index 81cc196de..296fb7531 100644 --- a/client/src/components/ListPane.tsx +++ b/client/src/components/ListPane.tsx @@ -1,4 +1,7 @@ +import { Search } from "lucide-react"; import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { useState, useMemo, useRef } from "react"; type ListPaneProps = { items: T[]; @@ -20,41 +23,109 @@ const ListPane = ({ title, buttonText, isButtonDisabled, -}: ListPaneProps) => ( -
-
-

{title}

-
-
- - -
- {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 079/281] 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 000000000..672bad042 --- /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 080/281] 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 296fb7531..09c9f57da 100644 --- a/client/src/components/ListPane.tsx +++ b/client/src/components/ListPane.tsx @@ -57,6 +57,8 @@ const ListPane = ({

{title}

Click the link to authorize in your browser. After @@ -354,7 +366,18 @@ export const OAuthFlowProgress = ({ authState.authorizationUrl && ( diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 78d52a59e..c1505398e 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -11,6 +11,7 @@ import { import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; import { SESSION_KEYS, getServerSpecificKey } from "./constants"; import { generateOAuthState } from "@/utils/oauthUtils"; +import { validateRedirectUrl } from "@/utils/urlValidation"; /** * Discovers OAuth scopes from server metadata, with preference for resource metadata scopes @@ -182,12 +183,8 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { } redirectToAuthorization(authorizationUrl: URL) { - if ( - authorizationUrl.protocol !== "http:" && - authorizationUrl.protocol !== "https:" - ) { - throw new Error("Authorization URL must be HTTP or HTTPS"); - } + // Validate the URL using the shared utility + validateRedirectUrl(authorizationUrl.href); window.location.href = authorizationUrl.href; } diff --git a/client/src/utils/__tests__/urlValidation.test.ts b/client/src/utils/__tests__/urlValidation.test.ts new file mode 100644 index 000000000..279197cb5 --- /dev/null +++ b/client/src/utils/__tests__/urlValidation.test.ts @@ -0,0 +1,127 @@ +import { validateRedirectUrl } from "../urlValidation"; + +describe("validateRedirectUrl", () => { + describe("valid URLs", () => { + it("should allow HTTP URLs", () => { + expect(() => validateRedirectUrl("http://example.com")).not.toThrow(); + }); + + it("should allow HTTPS URLs", () => { + expect(() => validateRedirectUrl("https://example.com")).not.toThrow(); + }); + + it("should allow URLs with ports", () => { + expect(() => validateRedirectUrl("https://example.com:8080")).not.toThrow(); + }); + + it("should allow URLs with paths", () => { + expect(() => validateRedirectUrl("https://example.com/path/to/auth")).not.toThrow(); + }); + + it("should allow URLs with query parameters", () => { + expect(() => validateRedirectUrl("https://example.com?param=value")).not.toThrow(); + }); + }); + + describe("invalid URLs - XSS vectors", () => { + it("should block javascript: protocol", () => { + expect(() => validateRedirectUrl("javascript:alert('XSS')")).toThrow( + "Authorization URL must be HTTP or HTTPS" + ); + }); + + it("should block javascript: with encoded characters", () => { + expect(() => validateRedirectUrl("javascript:alert%28%27XSS%27%29")).toThrow( + "Authorization URL must be HTTP or HTTPS" + ); + }); + + it("should block data: protocol", () => { + expect(() => validateRedirectUrl("data:text/html,")).toThrow( + "Authorization URL must be HTTP or HTTPS" + ); + }); + + it("should block vbscript: protocol", () => { + expect(() => validateRedirectUrl("vbscript:msgbox")).toThrow( + "Authorization URL must be HTTP or HTTPS" + ); + }); + + it("should block file: protocol", () => { + expect(() => validateRedirectUrl("file:///etc/passwd")).toThrow( + "Authorization URL must be HTTP or HTTPS" + ); + }); + + it("should block about: protocol", () => { + expect(() => validateRedirectUrl("about:blank")).toThrow( + "Authorization URL must be HTTP or HTTPS" + ); + }); + + it("should block custom protocols", () => { + expect(() => validateRedirectUrl("custom://example")).toThrow( + "Authorization URL must be HTTP or HTTPS" + ); + }); + }); + + describe("edge cases", () => { + it("should handle malformed URLs", () => { + expect(() => validateRedirectUrl("not a url")).toThrow( + "Invalid URL: not a url" + ); + }); + + it("should handle empty string", () => { + expect(() => validateRedirectUrl("")).toThrow( + "Invalid URL: " + ); + }); + + it("should handle URLs with unicode characters", () => { + expect(() => validateRedirectUrl("https://例え.jp")).not.toThrow(); + }); + + it("should handle URLs with case variations", () => { + expect(() => validateRedirectUrl("HTTPS://EXAMPLE.COM")).not.toThrow(); + expect(() => validateRedirectUrl("HtTpS://example.com")).not.toThrow(); + }); + + it("should handle protocol-relative URLs as invalid", () => { + expect(() => validateRedirectUrl("//example.com")).toThrow( + "Invalid URL: //example.com" + ); + }); + + it("should handle URLs with authentication", () => { + expect(() => validateRedirectUrl("https://user:pass@example.com")).not.toThrow(); + }); + }); + + describe("security considerations", () => { + it("should not be fooled by whitespace", () => { + expect(() => validateRedirectUrl(" javascript:alert('XSS')")).toThrow(); + expect(() => validateRedirectUrl("javascript:alert('XSS') ")).toThrow(); + }); + + it("should handle null bytes", () => { + expect(() => validateRedirectUrl("java\x00script:alert('XSS')")).toThrow(); + }); + + it("should handle tab characters", () => { + expect(() => validateRedirectUrl("java\tscript:alert('XSS')")).toThrow(); + }); + + it("should handle newlines", () => { + expect(() => validateRedirectUrl("java\nscript:alert('XSS')")).toThrow(); + }); + + it("should handle mixed case protocols", () => { + expect(() => validateRedirectUrl("JaVaScRiPt:alert('XSS')")).toThrow( + "Authorization URL must be HTTP or HTTPS" + ); + }); + }); +}); \ No newline at end of file diff --git a/client/src/utils/urlValidation.ts b/client/src/utils/urlValidation.ts new file mode 100644 index 000000000..fb0ef76b1 --- /dev/null +++ b/client/src/utils/urlValidation.ts @@ -0,0 +1,21 @@ +/** + * Validates that a URL is safe for redirection. + * Only allows HTTP and HTTPS protocols to prevent XSS attacks. + * + * @param url - The URL string to validate + * @throws Error if the URL has an unsafe protocol + */ +export function validateRedirectUrl(url: string): void { + try { + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + throw new Error("Authorization URL must be HTTP or HTTPS"); + } + } catch (error) { + if (error instanceof Error && error.message === "Authorization URL must be HTTP or HTTPS") { + throw error; + } + // If URL parsing fails, it's also invalid + throw new Error(`Invalid URL: ${url}`); + } +} \ No newline at end of file From 9e47d7d3c13bb7a83741f860d4bfc86c7a5d2078 Mon Sep 17 00:00:00 2001 From: Nathan Heaps Date: Wed, 20 Aug 2025 21:06:06 -0400 Subject: [PATCH 093/281] fix: correct stdout buffering issues in spawned processes Adds stdio inheritance to prevent Node.js from dropping console output after 8192 characters due to premature stdout pipe closure before output finishes flushing. Without this, calls like this would truncate after 8192 characters, leading to broken json output: ``` npx --node-options=--inspect -y @modelcontextprotocol/inspector --cli --config .mcp.json --server notion --method tools/list ``` ``` # notion mcp server "notion": { "//": "doppler needs to expose OPENAPI_MCP_HEADERS (and NOTION_TOKEN within it)", "command": "bash", "args": [ "-c", "doppler run -p 'mcp' -c \"user_${MCP_USER:-$USER}\" -- npx -y @notionhq/notion-mcp-server" ] }, ``` You can see this in their [openapi spec](https://github.com/makenotion/notion-mcp-server/blob/main/scripts/notion-openapi.json), which powers their MCP server. --- cli/src/cli.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 13c7e492a..3a27ccb1b 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -104,6 +104,10 @@ async function runWebClient(args: Args): Promise { await spawnPromise("node", [inspectorClientPath, ...startArgs], { signal: abort.signal, echoOutput: true, + // pipe the stdout through here, prevents issues with buffering and + // dropping the end of console.out after 8192 chars due to node + // closing the stdout pipe before the output has finished flushing + stdio: "inherit", }); } catch (e) { if (!cancelled || process.env.DEBUG) throw e; @@ -142,6 +146,10 @@ async function runCli(args: Args): Promise { env: { ...process.env, ...args.envArgs }, signal: abort.signal, echoOutput: true, + // pipe the stdout through here, prevents issues with buffering and + // dropping the end of console.out after 8192 chars due to node + // closing the stdout pipe before the output has finished flushing + stdio: "inherit", }); } catch (e) { if (!cancelled || process.env.DEBUG) { From fa2e6b0c4089bafefaa4f6434f0f71e2abc9c92c Mon Sep 17 00:00:00 2001 From: Jenn Newton Date: Thu, 21 Aug 2025 15:31:53 +0000 Subject: [PATCH 094/281] Fix to properly open in new tab for OAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- client/src/components/OAuthFlowProgress.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 797d9f4d7..e50b7df49 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -246,11 +246,18 @@ export const OAuthFlowProgress = ({ onClick={() => { try { validateRedirectUrl(authState.authorizationUrl!); - window.open(authState.authorizationUrl!, "_blank"); + window.open( + authState.authorizationUrl!, + "_blank", + "noopener noreferrer", + ); } catch (error) { toast({ title: "Invalid URL", - description: error instanceof Error ? error.message : "The authorization URL is not valid", + description: + error instanceof Error + ? error.message + : "The authorization URL is not valid", variant: "destructive", }); } @@ -373,7 +380,10 @@ export const OAuthFlowProgress = ({ } catch (error) { toast({ title: "Invalid URL", - description: error instanceof Error ? error.message : "The authorization URL is not valid", + description: + error instanceof Error + ? error.message + : "The authorization URL is not valid", variant: "destructive", }); } From a018a6d7587b04f02d45dd473fa03b7fdf982d97 Mon Sep 17 00:00:00 2001 From: Jenn Newton Date: Thu, 21 Aug 2025 15:41:33 +0000 Subject: [PATCH 095/281] Apply Prettier formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- client/src/components/AuthDebugger.tsx | 5 +- .../src/utils/__tests__/urlValidation.test.ts | 54 +++++++++++-------- client/src/utils/urlValidation.ts | 9 ++-- 3 files changed, 40 insertions(+), 28 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index c8d59c424..e7acbd803 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -171,7 +171,8 @@ const AuthDebugger = ({ updateAuthState({ ...currentState, isInitiatingAuth: false, - latestError: error instanceof Error ? error : new Error(String(error)), + latestError: + error instanceof Error ? error : new Error(String(error)), statusMessage: { type: "error", message: `Invalid authorization URL: ${error instanceof Error ? error.message : String(error)}`, @@ -179,7 +180,7 @@ const AuthDebugger = ({ }); return; } - + // Store the current auth state before redirecting sessionStorage.setItem( SESSION_KEYS.AUTH_DEBUGGER_STATE, diff --git a/client/src/utils/__tests__/urlValidation.test.ts b/client/src/utils/__tests__/urlValidation.test.ts index 279197cb5..9ee6a4545 100644 --- a/client/src/utils/__tests__/urlValidation.test.ts +++ b/client/src/utils/__tests__/urlValidation.test.ts @@ -11,58 +11,64 @@ describe("validateRedirectUrl", () => { }); it("should allow URLs with ports", () => { - expect(() => validateRedirectUrl("https://example.com:8080")).not.toThrow(); + expect(() => + validateRedirectUrl("https://example.com:8080"), + ).not.toThrow(); }); it("should allow URLs with paths", () => { - expect(() => validateRedirectUrl("https://example.com/path/to/auth")).not.toThrow(); + expect(() => + validateRedirectUrl("https://example.com/path/to/auth"), + ).not.toThrow(); }); it("should allow URLs with query parameters", () => { - expect(() => validateRedirectUrl("https://example.com?param=value")).not.toThrow(); + expect(() => + validateRedirectUrl("https://example.com?param=value"), + ).not.toThrow(); }); }); describe("invalid URLs - XSS vectors", () => { it("should block javascript: protocol", () => { expect(() => validateRedirectUrl("javascript:alert('XSS')")).toThrow( - "Authorization URL must be HTTP or HTTPS" + "Authorization URL must be HTTP or HTTPS", ); }); it("should block javascript: with encoded characters", () => { - expect(() => validateRedirectUrl("javascript:alert%28%27XSS%27%29")).toThrow( - "Authorization URL must be HTTP or HTTPS" - ); + expect(() => + validateRedirectUrl("javascript:alert%28%27XSS%27%29"), + ).toThrow("Authorization URL must be HTTP or HTTPS"); }); it("should block data: protocol", () => { - expect(() => validateRedirectUrl("data:text/html,")).toThrow( - "Authorization URL must be HTTP or HTTPS" - ); + expect(() => + validateRedirectUrl("data:text/html,"), + ).toThrow("Authorization URL must be HTTP or HTTPS"); }); it("should block vbscript: protocol", () => { expect(() => validateRedirectUrl("vbscript:msgbox")).toThrow( - "Authorization URL must be HTTP or HTTPS" + "Authorization URL must be HTTP or HTTPS", ); }); it("should block file: protocol", () => { expect(() => validateRedirectUrl("file:///etc/passwd")).toThrow( - "Authorization URL must be HTTP or HTTPS" + "Authorization URL must be HTTP or HTTPS", ); }); it("should block about: protocol", () => { expect(() => validateRedirectUrl("about:blank")).toThrow( - "Authorization URL must be HTTP or HTTPS" + "Authorization URL must be HTTP or HTTPS", ); }); it("should block custom protocols", () => { expect(() => validateRedirectUrl("custom://example")).toThrow( - "Authorization URL must be HTTP or HTTPS" + "Authorization URL must be HTTP or HTTPS", ); }); }); @@ -70,14 +76,12 @@ describe("validateRedirectUrl", () => { describe("edge cases", () => { it("should handle malformed URLs", () => { expect(() => validateRedirectUrl("not a url")).toThrow( - "Invalid URL: not a url" + "Invalid URL: not a url", ); }); it("should handle empty string", () => { - expect(() => validateRedirectUrl("")).toThrow( - "Invalid URL: " - ); + expect(() => validateRedirectUrl("")).toThrow("Invalid URL: "); }); it("should handle URLs with unicode characters", () => { @@ -91,12 +95,14 @@ describe("validateRedirectUrl", () => { it("should handle protocol-relative URLs as invalid", () => { expect(() => validateRedirectUrl("//example.com")).toThrow( - "Invalid URL: //example.com" + "Invalid URL: //example.com", ); }); it("should handle URLs with authentication", () => { - expect(() => validateRedirectUrl("https://user:pass@example.com")).not.toThrow(); + expect(() => + validateRedirectUrl("https://user:pass@example.com"), + ).not.toThrow(); }); }); @@ -107,7 +113,9 @@ describe("validateRedirectUrl", () => { }); it("should handle null bytes", () => { - expect(() => validateRedirectUrl("java\x00script:alert('XSS')")).toThrow(); + expect(() => + validateRedirectUrl("java\x00script:alert('XSS')"), + ).toThrow(); }); it("should handle tab characters", () => { @@ -120,8 +128,8 @@ describe("validateRedirectUrl", () => { it("should handle mixed case protocols", () => { expect(() => validateRedirectUrl("JaVaScRiPt:alert('XSS')")).toThrow( - "Authorization URL must be HTTP or HTTPS" + "Authorization URL must be HTTP or HTTPS", ); }); }); -}); \ No newline at end of file +}); diff --git a/client/src/utils/urlValidation.ts b/client/src/utils/urlValidation.ts index fb0ef76b1..70bc458bb 100644 --- a/client/src/utils/urlValidation.ts +++ b/client/src/utils/urlValidation.ts @@ -1,7 +1,7 @@ /** * Validates that a URL is safe for redirection. * Only allows HTTP and HTTPS protocols to prevent XSS attacks. - * + * * @param url - The URL string to validate * @throws Error if the URL has an unsafe protocol */ @@ -12,10 +12,13 @@ export function validateRedirectUrl(url: string): void { throw new Error("Authorization URL must be HTTP or HTTPS"); } } catch (error) { - if (error instanceof Error && error.message === "Authorization URL must be HTTP or HTTPS") { + if ( + error instanceof Error && + error.message === "Authorization URL must be HTTP or HTTPS" + ) { throw error; } // If URL parsing fails, it's also invalid throw new Error(`Invalid URL: ${url}`); } -} \ No newline at end of file +} From 9dbd5cc85bd1d7f309392ab626452a988da6fcc3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 21 Aug 2025 18:01:29 -0400 Subject: [PATCH 096/281] * Remove handling of out-of-spec 'notification/stderr' messages. It's not a thing. See https://github.com/modelcontextprotocol/servers/pull/2469 * Inspect the stderr output of STDIO servers and attempt to assign an appropriate RFC 5424 Syslog Protocol level before sending a leveled logging message to the client --- client/src/App.tsx | 16 --------- client/src/components/Sidebar.tsx | 35 ------------------ client/src/lib/hooks/useConnection.ts | 10 +----- client/src/lib/notificationTypes.ts | 14 ++------ server/src/index.ts | 51 ++++++++++++++++++++++++--- 5 files changed, 49 insertions(+), 77 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index d6680c35b..fecd98399 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -34,7 +34,6 @@ import { useDraggablePane, useDraggableSidebar, } from "./lib/hooks/useDraggablePane"; -import { StdErrNotification } from "./lib/notificationTypes"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; @@ -106,9 +105,6 @@ const App = () => { >(getInitialTransportType); const [logLevel, setLogLevel] = useState("debug"); const [notifications, setNotifications] = useState([]); - const [stdErrNotifications, setStdErrNotifications] = useState< - StdErrNotification[] - >([]); const [roots, setRoots] = useState([]); const [env, setEnv] = useState>({}); @@ -224,12 +220,6 @@ const App = () => { onNotification: (notification) => { setNotifications((prev) => [...prev, notification as ServerNotification]); }, - onStdErrNotification: (notification) => { - setStdErrNotifications((prev) => [ - ...prev, - notification as StdErrNotification, - ]); - }, onPendingRequest: (request, resolve, reject) => { setPendingSampleRequests((prev) => [ ...prev, @@ -757,10 +747,6 @@ const App = () => { setLogLevel(level); }; - const clearStdErrNotifications = () => { - setStdErrNotifications([]); - }; - const AuthDebuggerWrapper = () => ( { setOauthScope={setOauthScope} onConnect={connectMcpServer} onDisconnect={disconnectMcpServer} - stdErrNotifications={stdErrNotifications} logLevel={logLevel} sendLogLevelRequest={sendLogLevelRequest} loggingSupported={!!serverCapabilities?.logging || false} - clearStdErrNotifications={clearStdErrNotifications} />

void; onConnect: () => void; onDisconnect: () => void; - stdErrNotifications: StdErrNotification[]; - clearStdErrNotifications: () => void; logLevel: LoggingLevel; sendLogLevelRequest: (level: LoggingLevel) => void; loggingSupported: boolean; @@ -93,8 +90,6 @@ const Sidebar = ({ setOauthScope, onConnect, onDisconnect, - stdErrNotifications, - clearStdErrNotifications, logLevel, sendLogLevelRequest, loggingSupported, @@ -760,36 +755,6 @@ const Sidebar = ({
)} - - {stdErrNotifications.length > 0 && ( - <> -
-
-

- Error output from MCP server -

- -
-
- {stdErrNotifications.map((notification, index) => ( -
- {notification.params.content} -
- ))} -
-
- - )}
diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index d3690f31e..8c44d51bb 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -36,7 +36,7 @@ import { useEffect, useState } from "react"; import { useToast } from "@/lib/hooks/useToast"; import { z } from "zod"; import { ConnectionStatus } from "../constants"; -import { Notification, StdErrNotificationSchema } from "../notificationTypes"; +import { Notification } from "../notificationTypes"; import { auth, discoverOAuthProtectedResourceMetadata, @@ -92,7 +92,6 @@ export function useConnection({ oauthScope, config, onNotification, - onStdErrNotification, onPendingRequest, onElicitationRequest, getRoots, @@ -505,13 +504,6 @@ export function useConnection({ }; } - if (onStdErrNotification) { - client.setNotificationHandler( - StdErrNotificationSchema, - onStdErrNotification, - ); - } - let capabilities; try { const transport = diff --git a/client/src/lib/notificationTypes.ts b/client/src/lib/notificationTypes.ts index 8627ccc6c..a956452a9 100644 --- a/client/src/lib/notificationTypes.ts +++ b/client/src/lib/notificationTypes.ts @@ -5,18 +5,8 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; -export const StdErrNotificationSchema = BaseNotificationSchema.extend({ - method: z.literal("notifications/stderr"), - params: z.object({ - content: z.string(), - }), -}); - export const NotificationSchema = ClientNotificationSchema.or( - StdErrNotificationSchema, -) - .or(ServerNotificationSchema) - .or(BaseNotificationSchema); + ServerNotificationSchema, +).or(BaseNotificationSchema); -export type StdErrNotification = z.infer; export type Notification = z.infer; diff --git a/server/src/index.ts b/server/src/index.ts index 0a0f7bcc2..657460179 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -401,24 +401,65 @@ app.get( (serverTransport as StdioClientTransport).stderr!.on("data", (chunk) => { if (chunk.toString().includes("MODULE_NOT_FOUND")) { + // Server command not found, remove transports + const message = "Command not found, transports removed"; webAppTransport.send({ jsonrpc: "2.0", - method: "notifications/stderr", + method: "notifications/message", params: { - content: "Command not found, transports removed", + level: "alert", + data: { + error: message, + }, }, }); webAppTransport.close(); serverTransport.close(); webAppTransports.delete(webAppTransport.sessionId); serverTransports.delete(webAppTransport.sessionId); - console.error("Command not found, transports removed"); + console.error(message); } else { + // Inspect message and attempt to assign a RFC 5424 Syslog Protocol level + let level; + let message = chunk.toString(); + let ucMsg = chunk.toString().toUpperCase(); + if (ucMsg.includes("DEBUG")) { + level = "debug"; + } else if (ucMsg.includes("INFO")) { + level = "info"; + } else if (ucMsg.includes("NOTICE")) { + level = "notice"; + } else if (ucMsg.includes("WARN")) { + level = "warning"; + } else if (ucMsg.includes("ERROR")) { + level = "error"; + } else if (ucMsg.includes("CRITICAL")) { + level = "critical"; + } else if (ucMsg.includes("ALERT")) { + level = "alert"; + } else if (ucMsg.includes("EMERGENCY")) { + level = "emergency"; + } else if (ucMsg.includes("SIGINT")) { + level = "alert"; + message = "SIGINT received. Server shutdown."; + } else if (ucMsg.includes("SIGHUP")) { + level = "alert"; + message = "SIGHUP received. Server shutdown."; + } else if (ucMsg.includes("SIGTERM")) { + level = "alert"; + message = "SIGTERM received. Server shutdown."; + } else { + level = "info"; + } webAppTransport.send({ jsonrpc: "2.0", - method: "notifications/stderr", + method: "notifications/message", params: { - content: chunk.toString(), + level, + logger: "stdio", + data: { + error: message, + }, }, }); } From ec99372d925e24c46f8470e96b02c1116bef6242 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 21 Aug 2025 18:04:07 -0400 Subject: [PATCH 097/281] * Remove handling of out-of-spec 'notification/stderr' messages. It's not a thing. See https://github.com/modelcontextprotocol/servers/pull/2469 * Inspect the stderr output of STDIO servers and attempt to assign an appropriate RFC 5424 Syslog Protocol level before sending a leveled logging message to the client --- server/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/index.ts b/server/src/index.ts index 657460179..284424e5c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -458,7 +458,7 @@ app.get( level, logger: "stdio", data: { - error: message, + message, }, }, }); From f472327ea0174cf198d365c3e722617bf24290f9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 21 Aug 2025 18:12:38 -0400 Subject: [PATCH 098/281] Trim message before sending to client --- server/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/index.ts b/server/src/index.ts index 284424e5c..fc87aa0f8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -421,7 +421,7 @@ app.get( } else { // Inspect message and attempt to assign a RFC 5424 Syslog Protocol level let level; - let message = chunk.toString(); + let message = chunk.toString().trim(); let ucMsg = chunk.toString().toUpperCase(); if (ucMsg.includes("DEBUG")) { level = "debug"; From c54215252e949b5b2ec3cc878c06b8b49de21708 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 21 Aug 2025 18:40:19 -0400 Subject: [PATCH 099/281] Trim message before sending to client --- server/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/index.ts b/server/src/index.ts index fc87aa0f8..dc3351d77 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -409,7 +409,7 @@ app.get( params: { level: "alert", data: { - error: message, + message, }, }, }); From fb0b56505aa985146dcee6c8c8753fd9b40512d7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 21 Aug 2025 18:55:58 -0400 Subject: [PATCH 100/281] Trim message before sending to client --- server/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/index.ts b/server/src/index.ts index dc3351d77..11e360602 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -408,6 +408,7 @@ app.get( method: "notifications/message", params: { level: "alert", + logger: "proxy", data: { message, }, From 4cf7aeb9edf7320bf98f3777abdb0ace36c2c12d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 21 Aug 2025 19:08:51 -0400 Subject: [PATCH 101/281] proper levels for server shutdown messages --- server/src/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 11e360602..1f517d574 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -407,7 +407,7 @@ app.get( jsonrpc: "2.0", method: "notifications/message", params: { - level: "alert", + level: "emergency", logger: "proxy", data: { message, @@ -441,14 +441,14 @@ app.get( } else if (ucMsg.includes("EMERGENCY")) { level = "emergency"; } else if (ucMsg.includes("SIGINT")) { - level = "alert"; message = "SIGINT received. Server shutdown."; + level = "emergency"; } else if (ucMsg.includes("SIGHUP")) { - level = "alert"; message = "SIGHUP received. Server shutdown."; + level = "emergency"; } else if (ucMsg.includes("SIGTERM")) { - level = "alert"; message = "SIGTERM received. Server shutdown."; + level = "emergency"; } else { level = "info"; } From 3628abca9ee626f0469ebc609e80dd30bfeede21 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Sun, 10 Aug 2025 13:06:19 -0700 Subject: [PATCH 102/281] Use MCP_PROXY_FULL_ADDRESS for SSE transport backed endpoints The STDIO and SSE transports use SSE between the client and the proxy server. SSE requires the client to initiate the MCP conversation (event-stream) at the endpoint provided by the server. The endpoint provided by the server must obey where the client sees the server, rather than being assumed to be server-root `/message`. The Streamable HTTP endpoint does not need the full proxy address because the response is an event-stream directly, and does not use SSE. --- client/src/lib/hooks/useConnection.ts | 24 ++++++++++++++++++++++-- server/src/index.ts | 12 ++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 8c44d51bb..a07b67dc9 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -409,11 +409,20 @@ export function useConnection({ let mcpProxyServerUrl; switch (transportType) { - case "stdio": + 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 proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS + .value as string; + if (proxyFullAddress) { + mcpProxyServerUrl.searchParams.append( + "proxyFullAddress", + proxyFullAddress, + ); + } transportOptions = { authProvider: serverAuthProvider, eventSourceInit: { @@ -431,10 +440,20 @@ export function useConnection({ }, }; break; + } - case "sse": + 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: ( @@ -451,6 +470,7 @@ export function useConnection({ }, }; break; + } case "streamable-http": mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`); diff --git a/server/src/index.ts b/server/src/index.ts index a30c1845e..c55a79d34 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -391,7 +391,11 @@ app.get( throw error; } - const webAppTransport = new SSEServerTransport("/message", res); + const proxyFullAddress = (req.query.proxyFullAddress as string) || ""; + const prefix = proxyFullAddress || ""; + const endpoint = `${prefix}/message`; + + const webAppTransport = new SSEServerTransport(endpoint, res); console.log("Created client transport"); webAppTransports.set(webAppTransport.sessionId, webAppTransport); @@ -511,7 +515,11 @@ app.get( } if (serverTransport) { - const webAppTransport = new SSEServerTransport("/message", res); + const proxyFullAddress = (req.query.proxyFullAddress as string) || ""; + const prefix = proxyFullAddress || ""; + const endpoint = `${prefix}/message`; + + const webAppTransport = new SSEServerTransport(endpoint, res); webAppTransports.set(webAppTransport.sessionId, webAppTransport); console.log("Created client transport"); serverTransports.set(webAppTransport.sessionId, serverTransport!); From 89740ef5d111eefcc47924c5e17708cd126f5402 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Fri, 22 Aug 2025 00:47:22 -0700 Subject: [PATCH 103/281] Add tests for MCP_PROXY_FULL_ADDRESS SSE endpoint support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests verify that proxyFullAddress query parameter is sent for both STDIO and SSE transports, but not Streamable HTTP, when MCP_PROXY_FULL_ADDRESS is configured. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../hooks/__tests__/useConnection.test.tsx | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 7518f814e..624583dc7 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -877,4 +877,129 @@ describe("useConnection", () => { }); }); }); + + describe("MCP_PROXY_FULL_ADDRESS Configuration", () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset the mock transport objects + mockSSETransport.url = undefined; + mockSSETransport.options = undefined; + mockStreamableHTTPTransport.url = undefined; + mockStreamableHTTPTransport.options = undefined; + }); + + test("sends proxyFullAddress query parameter for stdio transport when configured", async () => { + const propsWithProxyFullAddress = { + ...defaultProps, + transportType: "stdio" as const, + command: "test-command", + args: "test-args", + env: {}, + config: { + ...DEFAULT_INSPECTOR_CONFIG, + MCP_PROXY_FULL_ADDRESS: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS, + value: "https://example.com/inspector/mcp_proxy", + }, + }, + }; + + const { result } = renderHook(() => + useConnection(propsWithProxyFullAddress), + ); + + await act(async () => { + await result.current.connect(); + }); + + // Check that the URL contains the proxyFullAddress parameter + expect(mockSSETransport.url?.searchParams.get("proxyFullAddress")).toBe( + "https://example.com/inspector/mcp_proxy", + ); + }); + + test("sends proxyFullAddress query parameter for sse transport when configured", async () => { + const propsWithProxyFullAddress = { + ...defaultProps, + transportType: "sse" as const, + sseUrl: "http://localhost:8080", + config: { + ...DEFAULT_INSPECTOR_CONFIG, + MCP_PROXY_FULL_ADDRESS: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS, + value: "https://example.com/inspector/mcp_proxy", + }, + }, + }; + + const { result } = renderHook(() => + useConnection(propsWithProxyFullAddress), + ); + + await act(async () => { + await result.current.connect(); + }); + + // Check that the URL contains the proxyFullAddress parameter + expect(mockSSETransport.url?.searchParams.get("proxyFullAddress")).toBe( + "https://example.com/inspector/mcp_proxy", + ); + }); + + test("does not send proxyFullAddress parameter when MCP_PROXY_FULL_ADDRESS is empty", async () => { + const propsWithEmptyProxy = { + ...defaultProps, + transportType: "stdio" as const, + command: "test-command", + args: "test-args", + env: {}, + config: { + ...DEFAULT_INSPECTOR_CONFIG, + MCP_PROXY_FULL_ADDRESS: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS, + value: "", + }, + }, + }; + + const { result } = renderHook(() => useConnection(propsWithEmptyProxy)); + + await act(async () => { + await result.current.connect(); + }); + + // Check that the URL does not contain the proxyFullAddress parameter + expect( + mockSSETransport.url?.searchParams.get("proxyFullAddress"), + ).toBeNull(); + }); + + test("does not send proxyFullAddress parameter for streamable-http transport", async () => { + const propsWithStreamableHttp = { + ...defaultProps, + transportType: "streamable-http" as const, + sseUrl: "http://localhost:8080", + config: { + ...DEFAULT_INSPECTOR_CONFIG, + MCP_PROXY_FULL_ADDRESS: { + ...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS, + value: "https://example.com/inspector/mcp_proxy", + }, + }, + }; + + const { result } = renderHook(() => + useConnection(propsWithStreamableHttp), + ); + + await act(async () => { + await result.current.connect(); + }); + + // Check that streamable-http transport doesn't get proxyFullAddress parameter + expect( + mockStreamableHTTPTransport.url?.searchParams.get("proxyFullAddress"), + ).toBeNull(); + }); + }); }); From 61db7918af20ab2aca6884365fb1b2a03f601f3f Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Tue, 12 Aug 2025 10:15:02 -0700 Subject: [PATCH 104/281] Align similar `/sse` and `/stdio` handlers: formatting and move log statement --- server/src/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index c55a79d34..c0fb3797a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -378,7 +378,6 @@ app.get( let serverTransport: Transport | undefined; try { serverTransport = await createTransport(req); - console.log("Created server transport"); } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( @@ -396,10 +395,11 @@ app.get( const endpoint = `${prefix}/message`; const webAppTransport = new SSEServerTransport(endpoint, res); + webAppTransports.set(webAppTransport.sessionId, webAppTransport); console.log("Created client transport"); - webAppTransports.set(webAppTransport.sessionId, webAppTransport); serverTransports.set(webAppTransport.sessionId, serverTransport); + console.log("Created server transport"); await webAppTransport.start(); @@ -488,7 +488,7 @@ app.get( async (req, res) => { try { console.log( - "New SSE connection request. NOTE: The sse transport is deprecated and has been replaced by StreamableHttp", + "New SSE connection request. NOTE: The SSE transport is deprecated and has been replaced by StreamableHttp", ); let serverTransport: Transport | undefined; try { @@ -522,6 +522,7 @@ app.get( const webAppTransport = new SSEServerTransport(endpoint, res); webAppTransports.set(webAppTransport.sessionId, webAppTransport); console.log("Created client transport"); + serverTransports.set(webAppTransport.sessionId, serverTransport!); console.log("Created server transport"); From d7b020fdda8fd751fa5e6b1df9cbf0490fb30274 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Sun, 15 Jun 2025 14:04:17 -0700 Subject: [PATCH 105/281] Fix hard-coded CLI version, use package.json consistent with client --- cli/src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/src/index.ts b/cli/src/index.ts index 2b0c4f53d..dea6e296b 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -20,6 +20,8 @@ import { import { handleError } from "./error-handler.js"; import { createTransport, TransportOptions } from "./transport.js"; +import packageJson from "../package.json" with { type: "json" }; + type Args = { target: string[]; method?: string; @@ -89,7 +91,7 @@ async function callMethod(args: Args): Promise { const transport = createTransport(transportOptions); const client = new Client({ name: "inspector-cli", - version: "0.5.1", + version: packageJson.version, }); try { From f942df7e4c1f53fead4102cf14b635797d59de84 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Tue, 5 Aug 2025 10:52:56 -0700 Subject: [PATCH 106/281] Consistent MCP Client() name from package Make both clients use name from package.json for consistency and protocol clarity - web client changes `mcp-inspector` -> `inspector-client` (package.json) - cli client remains `inspector-cli` (package.json) --- cli/src/index.ts | 10 ++++++---- client/src/lib/hooks/useConnection.ts | 27 +++++++++++++++------------ 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/cli/src/index.ts b/cli/src/index.ts index dea6e296b..aa6492d47 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -89,10 +89,12 @@ function createTransportOptions( async function callMethod(args: Args): Promise { const transportOptions = createTransportOptions(args.target, args.transport); const transport = createTransport(transportOptions); - const client = new Client({ - name: "inspector-cli", - version: packageJson.version, - }); + + const [, name = packageJson.name] = packageJson.name.split("/"); + const version = packageJson.version; + const clientIdentity = { name, version }; + + const client = new Client(clientIdentity); try { await connect(client, transport); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 8c44d51bb..92029d2cf 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -347,20 +347,23 @@ export function useConnection({ }; const connect = async (_e?: unknown, retryCount: number = 0) => { - const client = new Client( - { - name: "mcp-inspector", - version: packageJson.version, - }, - { - capabilities: { - sampling: {}, - elicitation: {}, - roots: { - listChanged: true, - }, + const [, name = packageJson.name] = packageJson.name.split("/"); + const version = packageJson.version; + const clientIdentity = { name, version }; + + const clientCapabilities = { + capabilities: { + sampling: {}, + elicitation: {}, + roots: { + listChanged: true, }, }, + }; + + const client = new Client( + clientIdentity, + clientCapabilities, ); try { From 0d202ee47b9ebfd8f591f0da554eed32fa04772a Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Tue, 5 Aug 2025 12:20:36 -0700 Subject: [PATCH 107/281] Update client name during Elicitation test init --- client/src/lib/hooks/__tests__/useConnection.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 7518f814e..3cf037841 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -247,7 +247,7 @@ describe("useConnection", () => { expect(Client).toHaveBeenCalledWith( expect.objectContaining({ - name: "mcp-inspector", + name: "inspector-client", version: expect.any(String), }), expect.objectContaining({ From ae7d745d5e5c5ea9979b3192272127c98fa76eb6 Mon Sep 17 00:00:00 2001 From: Richard Michael Date: Fri, 22 Aug 2025 10:47:36 -0700 Subject: [PATCH 108/281] Ensure tests will always use the current client name and version --- client/src/lib/constants.ts | 8 ++++++++ client/src/lib/hooks/__tests__/useConnection.test.tsx | 6 +++--- client/src/lib/hooks/useConnection.ts | 9 ++------- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/client/src/lib/constants.ts b/client/src/lib/constants.ts index 2e983302e..8e4ff2b56 100644 --- a/client/src/lib/constants.ts +++ b/client/src/lib/constants.ts @@ -1,4 +1,12 @@ import { InspectorConfig } from "./configurationTypes"; +import packageJson from "../../package.json"; + +// Client identity for MCP connections +export const CLIENT_IDENTITY = (() => { + const [, name = packageJson.name] = packageJson.name.split("/"); + const version = packageJson.version; + return { name, version }; +})(); // OAuth-related session storage keys export const SESSION_KEYS = { diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 3cf037841..109464163 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -2,7 +2,7 @@ import { renderHook, act } from "@testing-library/react"; import { useConnection } from "../useConnection"; import { z } from "zod"; import { ClientRequest } from "@modelcontextprotocol/sdk/types.js"; -import { DEFAULT_INSPECTOR_CONFIG } from "../../constants"; +import { DEFAULT_INSPECTOR_CONFIG, CLIENT_IDENTITY } from "../../constants"; import { SSEClientTransportOptions, SseError, @@ -247,8 +247,8 @@ describe("useConnection", () => { expect(Client).toHaveBeenCalledWith( expect.objectContaining({ - name: "inspector-client", - version: expect.any(String), + name: CLIENT_IDENTITY.name, + version: CLIENT_IDENTITY.version, }), expect.objectContaining({ capabilities: expect.objectContaining({ diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 92029d2cf..129cdd3e8 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -35,7 +35,7 @@ import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { useEffect, useState } from "react"; import { useToast } from "@/lib/hooks/useToast"; import { z } from "zod"; -import { ConnectionStatus } from "../constants"; +import { ConnectionStatus, CLIENT_IDENTITY } from "../constants"; import { Notification } from "../notificationTypes"; import { auth, @@ -47,7 +47,6 @@ import { saveClientInformationToSessionStorage, discoverScopes, } from "../auth"; -import packageJson from "../../../package.json"; import { getMCPProxyAddress, getMCPServerRequestMaxTotalTimeout, @@ -347,10 +346,6 @@ export function useConnection({ }; const connect = async (_e?: unknown, retryCount: number = 0) => { - const [, name = packageJson.name] = packageJson.name.split("/"); - const version = packageJson.version; - const clientIdentity = { name, version }; - const clientCapabilities = { capabilities: { sampling: {}, @@ -362,7 +357,7 @@ export function useConnection({ }; const client = new Client( - clientIdentity, + CLIENT_IDENTITY, clientCapabilities, ); From 8f2e53c273d66ec7db095f0c231f79523849db9d Mon Sep 17 00:00:00 2001 From: pavan-mellamputi-socure <104933528+pavan-mellamputi-socure@users.noreply.github.com> Date: Fri, 22 Aug 2025 18:48:26 -0700 Subject: [PATCH 109/281] Fix a typo in progress notification method name It shoud be `notifications/progress` not `notification/progress` (not singular). This matches the latest spec. https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress --- 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 8c44d51bb..f3f30e535 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -167,7 +167,7 @@ export function useConnection({ // Add progress notification to `Server Notification` window in the UI if (onNotification) { onNotification({ - method: "notification/progress", + method: "notifications/progress", params, }); } From c2e117a4a91e4b3d0d03818298661d905c1557a1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 25 Aug 2025 10:13:32 -0400 Subject: [PATCH 110/281] Add Docker container startup command to README.md --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 1f6cb822c..777f6eeb8 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,14 @@ npx @modelcontextprotocol/inspector The server will start up and the UI will be accessible at `http://localhost:6274`. +### Docker Container + +You can also start it in a Docker container with the following command: + +```bash +docker run --rm --network host -p 6274:6274 -p 6277:6277 ghcr.io/modelcontextprotocol/inspector:latest +``` + ### From an MCP server repository To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`: From 6853dcf42d4abfa8785fb1c7ee550bf626ee7db6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 25 Aug 2025 11:31:54 -0400 Subject: [PATCH 111/281] In Dockerfile - use current-alpine3.22 AS builder - The node:24-slim has a high vulnerability CVE as yet unfixed which allows running commands on the host as root --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index f36fb8bb7..c87e46658 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM node:24-slim AS builder +FROM current-alpine3.22 AS builder # Set working directory WORKDIR /app @@ -49,4 +49,4 @@ ENV SERVER_PORT=6277 EXPOSE ${CLIENT_PORT} ${SERVER_PORT} # Use ENTRYPOINT with CMD for arguments -ENTRYPOINT ["npm", "start"] \ No newline at end of file +ENTRYPOINT ["npm", "start"] From 98becdf5e081ee631af172b3c8079105128557e3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 25 Aug 2025 11:41:44 -0400 Subject: [PATCH 112/281] In Dockerfile - use current-alpine3.22 AS builder - The node:24-slim has a high vulnerability CVE as yet unfixed which allows running commands on the host as root --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c87e46658..d66091d16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM current-alpine3.22 AS builder +FROM node:current-alpine3.22 AS builder # Set working directory WORKDIR /app From ddf74d0766b951dbb9af970da62281b51ea8c7ed Mon Sep 17 00:00:00 2001 From: olaservo Date: Tue, 26 Aug 2025 10:00:03 -0700 Subject: [PATCH 113/281] Fix formatting --- .github/workflows/claude.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index da8e7c7af..6e916ebab 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -44,10 +44,10 @@ jobs: # Trigger when assigned to an issue assignee_trigger: "claude" - + # Allow Claude to run bash # This should be safe given the repo is already public allowed_tools: "Bash" - + custom_instructions: | If posting a comment to GitHub, give a concise summary of the comment at the top and put all the details in a
block. From 67f35363d8e130b57bbad7e1554f375c86e15c4c Mon Sep 17 00:00:00 2001 From: Chris Griffing Date: Tue, 26 Aug 2025 16:14:44 -0700 Subject: [PATCH 114/281] Make cli output awaitable to prevent truncating the result --- cli/src/index.ts | 3 ++- cli/src/utils/awaitable-log.ts | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 cli/src/utils/awaitable-log.ts diff --git a/cli/src/index.ts b/cli/src/index.ts index 2b0c4f53d..a7e21c76d 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -19,6 +19,7 @@ import { } from "./client/index.js"; import { handleError } from "./error-handler.js"; import { createTransport, TransportOptions } from "./transport.js"; +import { awaitableLog } from "./utils/awaitable-log.js"; type Args = { target: string[]; @@ -150,7 +151,7 @@ async function callMethod(args: Args): Promise { ); } - console.log(JSON.stringify(result, null, 2)); + await awaitableLog(JSON.stringify(result, null, 2)); } finally { try { await disconnect(transport); diff --git a/cli/src/utils/awaitable-log.ts b/cli/src/utils/awaitable-log.ts new file mode 100644 index 000000000..144f01123 --- /dev/null +++ b/cli/src/utils/awaitable-log.ts @@ -0,0 +1,7 @@ +export function awaitableLog(logValue: string): Promise { + return new Promise((resolve) => { + process.stdout.write(logValue, () => { + resolve(); + }); + }); +} From 03d48f37877513ca29100dfe698373d7bbb805a5 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 27 Aug 2025 20:40:28 -0700 Subject: [PATCH 115/281] Add example to Readme of calling a tool with JSON arguments --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 777f6eeb8..a0716556f 100644 --- a/README.md +++ b/README.md @@ -395,6 +395,9 @@ npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/lis # Call a specific tool npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name mytool --tool-arg key=value --tool-arg another=value2 +# Call a tool with JSON arguments +npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/call --tool-name mytool --tool-arg 'options={"format": "json", "max_tokens": 100}' + # List available resources npx @modelcontextprotocol/inspector --cli node build/index.js --method resources/list From cf3c0ec470e68bf92d17ce0d163479e3fe18343b Mon Sep 17 00:00:00 2001 From: "lorenzo.neumann" <36760115+ln-12@users.noreply.github.com> Date: Thu, 28 Aug 2025 12:20:27 +0200 Subject: [PATCH 116/281] added copy functionality to JsonView component --- client/src/components/JsonView.tsx | 102 ++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 3 deletions(-) diff --git a/client/src/components/JsonView.tsx b/client/src/components/JsonView.tsx index e9ef0d2d7..575b13758 100644 --- a/client/src/components/JsonView.tsx +++ b/client/src/components/JsonView.tsx @@ -1,4 +1,5 @@ import { useState, memo, useMemo, useCallback, useEffect } from "react"; +import type React from "react"; import type { JsonValue } from "@/utils/jsonUtils"; import clsx from "clsx"; import { Copy, CheckCheck } from "lucide-react"; @@ -114,6 +115,7 @@ const JsonNode = memo( initialExpandDepth, isError = false, }: JsonNodeProps) => { + const { toast } = useToast(); const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth); const [typeStyleMap] = useState>({ number: "text-blue-600", @@ -126,6 +128,52 @@ const JsonNode = memo( }); const dataType = getDataType(data); + const [copied, setCopied] = useState(false); + useEffect(() => { + let timeoutId: NodeJS.Timeout; + if (copied) { + timeoutId = setTimeout(() => setCopied(false), 500); + } + return () => { + if (timeoutId) clearTimeout(timeoutId); + }; + }, [copied]); + + const handleCopyValue = useCallback( + (value: JsonValue) => { + try { + let text: string; + const valueType = getDataType(value); + switch (valueType) { + case "string": + text = value as unknown as string; + break; + case "number": + case "boolean": + text = String(value); + break; + case "null": + text = "null"; + break; + case "undefined": + text = "undefined"; + break; + default: + text = JSON.stringify(value); + } + navigator.clipboard.writeText(text); + setCopied(true); + } catch (error) { + toast({ + title: "Error", + description: `There was an error coping result into the clipboard: ${error instanceof Error ? error.message : String(error)}`, + variant: "destructive", + }); + } + }, + [toast], + ); + const renderCollapsible = (isArray: boolean) => { const items = isArray ? (data as JsonValue[]) @@ -219,7 +267,7 @@ const JsonNode = memo( if (!isTooLong) { return ( -
+
{name && ( {name}: @@ -233,12 +281,28 @@ const JsonNode = memo( > "{value}" +
); } return ( -
+
{name && ( {name}: @@ -254,6 +318,22 @@ const JsonNode = memo( > {isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`} +
); }; @@ -266,7 +346,7 @@ const JsonNode = memo( return renderString(data as string); default: return ( -
+
{name && ( {name}: @@ -275,6 +355,22 @@ const JsonNode = memo( {data === null ? "null" : String(data)} +
); } From 6b26dd3fda874d8beb7f641444d260d9f1134361 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Thu, 28 Aug 2025 16:09:04 -0700 Subject: [PATCH 117/281] Fix issue 766 --- client/src/components/PromptsTab.tsx | 15 ++++++++++++--- client/src/components/ui/combobox.tsx | 14 +++++++++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx index fa5905580..0df36bb87 100644 --- a/client/src/components/PromptsTab.tsx +++ b/client/src/components/PromptsTab.tsx @@ -63,9 +63,7 @@ const PromptsTab = ({ clearCompletions(); }, [clearCompletions, selectedPrompt]); - const handleInputChange = async (argName: string, value: string) => { - setPromptArgs((prev) => ({ ...prev, [argName]: value })); - + const triggerCompletions = (argName: string, value: string) => { if (selectedPrompt) { requestCompletions( { @@ -79,6 +77,16 @@ const PromptsTab = ({ } }; + const handleInputChange = async (argName: string, value: string) => { + setPromptArgs((prev) => ({ ...prev, [argName]: value })); + triggerCompletions(argName, value); + }; + + const handleFocus = async (argName: string) => { + const currentValue = promptArgs[argName] || ""; + triggerCompletions(argName, currentValue); + }; + const handleGetPrompt = () => { if (selectedPrompt) { getPrompt(selectedPrompt.name, promptArgs); @@ -143,6 +151,7 @@ const PromptsTab = ({ onInputChange={(value) => handleInputChange(arg.name, value) } + onFocus={() => handleFocus(arg.name)} options={completions[arg.name] || []} /> diff --git a/client/src/components/ui/combobox.tsx b/client/src/components/ui/combobox.tsx index 026240869..9e6227b15 100644 --- a/client/src/components/ui/combobox.tsx +++ b/client/src/components/ui/combobox.tsx @@ -19,6 +19,7 @@ interface ComboboxProps { value: string; onChange: (value: string) => void; onInputChange: (value: string) => void; + onFocus?: () => void; options: string[]; placeholder?: string; emptyMessage?: string; @@ -29,6 +30,7 @@ export function Combobox({ value, onChange, onInputChange, + onFocus, options = [], placeholder = "Select...", emptyMessage = "No results found.", @@ -36,6 +38,16 @@ export function Combobox({ }: ComboboxProps) { const [open, setOpen] = React.useState(false); + const handleOpenChange = React.useCallback( + (newOpen: boolean) => { + setOpen(newOpen); + if (newOpen && onFocus) { + onFocus(); + } + }, + [onFocus], + ); + const handleSelect = React.useCallback( (option: string) => { onChange(option); @@ -52,7 +64,7 @@ export function Combobox({ ); return ( - + +
+ {metaEntries.length === 0 ? ( +

+ No meta pairs. +

+ ) : ( +
+ {metaEntries.map((entry, index) => ( +
+ + { + const value = e.target.value; + setMetaEntries((prev) => + prev.map((m, i) => + i === index ? { ...m, key: value } : m, + ), + ); + }} + className="h-8 flex-1" + /> + + { + const value = e.target.value; + setMetaEntries((prev) => + prev.map((m, i) => + i === index ? { ...m, value } : m, + ), + ); + }} + className="h-8 flex-1" + /> + +
+ ))} +
+ )} +
{selectedTool.outputSchema && (
@@ -262,7 +365,7 @@ const ToolsTab = ({ selectedTool._meta && (
-

Meta:

+

Meta Schema:

+
{requestHistory.length === 0 ? (

No history yet @@ -93,7 +108,17 @@ const HistoryAndNotifications = ({ )}

-

Server Notifications

+
+

Server Notifications

+ +
{serverNotifications.length === 0 ? (

No notifications yet diff --git a/client/src/components/__tests__/HistoryAndNotifications.test.tsx b/client/src/components/__tests__/HistoryAndNotifications.test.tsx index 42c585194..a813db8df 100644 --- a/client/src/components/__tests__/HistoryAndNotifications.test.tsx +++ b/client/src/components/__tests__/HistoryAndNotifications.test.tsx @@ -1,4 +1,5 @@ -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, within } from "@testing-library/react"; +import { useState } from "react"; import { describe, it, expect, jest } from "@jest/globals"; import HistoryAndNotifications from "../HistoryAndNotifications"; import { ServerNotification } from "@modelcontextprotocol/sdk/types.js"; @@ -223,4 +224,66 @@ describe("HistoryAndNotifications", () => { expect(screen.getByText("No history yet")).toBeTruthy(); expect(screen.getByText("No notifications yet")).toBeTruthy(); }); + + it("clears request history when Clear is clicked", () => { + const Wrapper = () => { + const [history, setHistory] = useState(mockRequestHistory); + return ( + setHistory([])} + /> + ); + }; + + render(); + + // Verify items are present initially + expect(screen.getByText("2. test/method2")).toBeTruthy(); + expect(screen.getByText("1. test/method1")).toBeTruthy(); + + // Click Clear in History header (scoped by the History heading's container) + const historyHeader = screen.getByText("History"); + const historyHeaderContainer = historyHeader.parentElement as HTMLElement; + const historyClearButton = within(historyHeaderContainer).getByRole( + "button", + { name: "Clear" }, + ); + fireEvent.click(historyClearButton); + + // History should now be empty + expect(screen.getByText("No history yet")).toBeTruthy(); + }); + + it("clears server notifications when Clear is clicked", () => { + const Wrapper = () => { + const [notifications, setNotifications] = + useState(mockNotifications); + return ( + setNotifications([])} + /> + ); + }; + + render(); + + // Verify items are present initially + expect(screen.getByText("2. notifications/progress")).toBeTruthy(); + expect(screen.getByText("1. notifications/message")).toBeTruthy(); + + // Click Clear in Server Notifications header (scoped by its heading's container) + const notifHeader = screen.getByText("Server Notifications"); + const notifHeaderContainer = notifHeader.parentElement as HTMLElement; + const notifClearButton = within(notifHeaderContainer).getByRole("button", { + name: "Clear", + }); + fireEvent.click(notifClearButton); + + // Notifications should now be empty + expect(screen.getByText("No notifications yet")).toBeTruthy(); + }); }); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 8f457910d..bfb72941c 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -653,11 +653,16 @@ export function useConnection({ setServerCapabilities(null); }; + const clearRequestHistory = () => { + setRequestHistory([]); + }; + return { connectionStatus, serverCapabilities, mcpClient, requestHistory, + clearRequestHistory, makeRequest, sendNotification, handleCompletion, From 71583caf3bd72aad73e4424e0c638588a13a35db Mon Sep 17 00:00:00 2001 From: Max Gerber Date: Fri, 5 Sep 2025 18:40:24 -0700 Subject: [PATCH 128/281] fix: correct authState.authorizationUrl type to URL --- client/src/components/AuthDebugger.tsx | 2 +- client/src/components/OAuthFlowProgress.tsx | 2 +- client/src/components/__tests__/AuthDebugger.test.tsx | 8 ++++++-- client/src/lib/auth-types.ts | 2 +- client/src/lib/oauth-state-machine.ts | 2 +- client/src/utils/urlValidation.ts | 2 +- 6 files changed, 11 insertions(+), 7 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index e7acbd803..6252c1161 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -187,7 +187,7 @@ const AuthDebugger = ({ JSON.stringify(currentState), ); // Open the authorization URL automatically - window.location.href = currentState.authorizationUrl; + window.location.href = currentState.authorizationUrl.toString(); break; } } diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index e50b7df49..5f44a4f51 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -240,7 +240,7 @@ export const OAuthFlowProgress = ({

Authorization URL:

- {authState.authorizationUrl} + {String(authState.authorizationUrl)}

+
+
+