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 01/25] 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 00000000..cb662d92 --- /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 2eb682e5..2654feed 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 338b642a..60f5b1d6 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 9fc7a724..d7210a24 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 02/25] 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 cb662d92..42c710c9 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 03/25] 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 42c710c9..00000000 --- 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 04/25] 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 d7210a24..62401bf8 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 05/25] 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 62401bf8..7a175142 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 06/25] 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 133e25ec..0ecd8075 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 07/25] 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 849a2aa2..aafa2933 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 08/25] 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 aafa2933..2214add9 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 22813c9b..318fba2d 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 acf6c1e118e3b117e06a1a5ea17aeeba3bf736de Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 30 Jul 2025 16:37:59 +0100 Subject: [PATCH 09/25] 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 5ff1f111..d620958b 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 e0496cde..93a39ad3 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 10/25] 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 68ce3885..857b5108 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 11/25] 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 93a39ad3..ddf3fac3 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 12/25] 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 857b5108..299c7e3d 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 13/25] 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 00000000..3376a6d7 --- /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 14/25] 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 299c7e3d..87981dd8 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 d620958b..cf8662e5 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 15/25] 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 6a671f1c..932c337d 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 16/25] 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 932c337d..69eada69 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 87981dd8..e737756f 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 cf8662e5..09c99d51 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 ddf3fac3..e0496cde 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 3376a6d7..a4dcdcce 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 17/25] 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 e737756f..675ea0bc 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 09c99d51..df16f0cc 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 18/25] 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 e0496cde..f67301de 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 537f80bf..d6680c35 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 92badc2c..34a69414 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 98b7e63bb032b6aae4767712af4359594271a29a Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 6 Aug 2025 19:50:23 +0100 Subject: [PATCH 19/25] 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 df16f0cc..6c72e6a4 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 e1e702ff14791e653535901f05b6545c504cae84 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 7 Aug 2025 11:42:31 +0100 Subject: [PATCH 20/25] ignore: ignore mcp.json as a common dev file --- .gitignore | 1 + .prettierignore | 1 + README.md | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index eeee17fe..6ce55639 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ sdk client/playwright-report/ client/results.json client/test-results/ +mcp.json diff --git a/.prettierignore b/.prettierignore index c8824c9a..cd910373 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ packages server/build CODE_OF_CONDUCT.md SECURITY.md +mcp.json diff --git a/README.md b/README.md index 69eada69..ed1e5093 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,7 @@ You can launch the inspector without specifying a server name if your config has ```bash # Automatically uses "my-server" if it's the only one -npx @modelcontextprotocol/inspector --config config.json +npx @modelcontextprotocol/inspector --config mcp.json ``` 2. **A server named "default-server"** - automatically selected: From cf7c138958a32b3b613d87a743b8271b8d082386 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 7 Aug 2025 11:51:36 +0100 Subject: [PATCH 21/25] Fix --server-url flag not being parsed by CLI Add --server-url option to CLI parser so the flag is properly recognized and passed through to the server process via the /config endpoint. --- cli/src/cli.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 6c72e6a4..4bc7b6b1 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -24,6 +24,7 @@ type CliOptions = { server?: string; cli?: boolean; transport?: string; + serverUrl?: string; }; type ServerConfig = @@ -224,7 +225,8 @@ function parseArgs(): Args { .option("--config ", "config file path") .option("--server ", "server name from config file") .option("--cli", "enable CLI mode") - .option("--transport ", "transport type (stdio, sse, http)"); + .option("--transport ", "transport type (stdio, sse, http)") + .option("--server-url ", "server URL for SSE/HTTP transport"); // Parse only the arguments before -- program.parse(preArgs); @@ -318,6 +320,7 @@ function parseArgs(): Args { envArgs: options.e || {}, cli: options.cli || false, transport: transport as "stdio" | "sse" | "streamable-http" | undefined, + serverUrl: options.serverUrl, }; } From 14b01815adb5f3f7333a086d58affee6e6b3c0b0 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 7 Aug 2025 12:01:17 +0100 Subject: [PATCH 22/25] prettier --- cli/src/cli.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 4bc7b6b1..177ac058 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -130,7 +130,8 @@ async function runCli(args: Args): Promise { // Add transport flag if specified if (args.transport && args.transport !== "stdio") { // Convert streamable-http back to http for CLI mode - const cliTransport = args.transport === "streamable-http" ? "http" : args.transport; + const cliTransport = + args.transport === "streamable-http" ? "http" : args.transport; cliArgs.push("--transport", cliTransport); } @@ -307,7 +308,7 @@ 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") { From 1263ffdf58433d547423e18d861c8de764bf719d Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 7 Aug 2025 20:45:23 +0100 Subject: [PATCH 23/25] Simplify server auto-selection logic Only auto-select when there's exactly one server in the config file. Removes special handling for "default-server" naming convention. Addresses review feedback from @ochafik on PR #661 --- cli/src/cli.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 177ac058..13c7e492 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -254,16 +254,13 @@ function parseArgs(): Args { 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) { + 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 + // Multiple servers, require explicit selection throw new Error( `Multiple servers found in config file. Please specify one with --server.\nAvailable servers: ${servers.join(", ")}`, ); From 68319704f07a39c1a4395ad131e4bd9487df4241 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 7 Aug 2025 21:02:29 +0100 Subject: [PATCH 24/25] Update test to match new server selection behavior Test 30 now expects an error when multiple servers exist, even with "default-server" present, aligning with the simplified logic that only auto-selects when there's exactly one server. --- cli/scripts/cli-tests.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js index 675ea0bc..f714cb6a 100755 --- a/cli/scripts/cli-tests.js +++ b/cli/scripts/cli-tests.js @@ -862,9 +862,9 @@ async function runTests() { "tools/list", ); - // Test 30: Config with default-server auto-selection - await runBasicTest( - "default_server_auto_select", + // Test 30: Config with default-server should now require explicit selection (multiple servers) + await runErrorTest( + "default_server_requires_explicit_selection", "--config", defaultServerConfigPath, "--cli", From b89b10beac5645114669e8f89ccf54fdecf4ed19 Mon Sep 17 00:00:00 2001 From: olaservo Date: Fri, 8 Aug 2025 15:46:58 -0700 Subject: [PATCH 25/25] Bump version --- cli/package.json | 2 +- client/package.json | 2 +- package-lock.json | 16 ++++++++-------- package.json | 8 ++++---- server/package.json | 2 +- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/cli/package.json b/cli/package.json index bf344733..6bb55253 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.16.2", + "version": "0.16.3", "description": "CLI for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/client/package.json b/client/package.json index cbb7d8b6..d1c6d775 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.16.2", + "version": "0.16.3", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/package-lock.json b/package-lock.json index 89c27a17..f088d270 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.16.2", + "version": "0.16.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "0.16.2", + "version": "0.16.3", "license": "MIT", "workspaces": [ "client", @@ -14,9 +14,9 @@ "cli" ], "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.16.2", - "@modelcontextprotocol/inspector-client": "^0.16.2", - "@modelcontextprotocol/inspector-server": "^0.16.2", + "@modelcontextprotocol/inspector-cli": "^0.16.3", + "@modelcontextprotocol/inspector-client": "^0.16.3", + "@modelcontextprotocol/inspector-server": "^0.16.3", "@modelcontextprotocol/sdk": "^1.17.0", "concurrently": "^9.2.0", "open": "^10.2.0", @@ -44,7 +44,7 @@ }, "cli": { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.16.2", + "version": "0.16.3", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.0", @@ -67,7 +67,7 @@ }, "client": { "name": "@modelcontextprotocol/inspector-client", - "version": "0.16.2", + "version": "0.16.3", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.0", @@ -11059,7 +11059,7 @@ }, "server": { "name": "@modelcontextprotocol/inspector-server", - "version": "0.16.2", + "version": "0.16.3", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.0", diff --git a/package.json b/package.json index e614ba21..9de5f464 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.16.2", + "version": "0.16.3", "description": "Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -47,9 +47,9 @@ "check-version": "node scripts/check-version-consistency.js" }, "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.16.2", - "@modelcontextprotocol/inspector-client": "^0.16.2", - "@modelcontextprotocol/inspector-server": "^0.16.2", + "@modelcontextprotocol/inspector-cli": "^0.16.3", + "@modelcontextprotocol/inspector-client": "^0.16.3", + "@modelcontextprotocol/inspector-server": "^0.16.3", "@modelcontextprotocol/sdk": "^1.17.0", "concurrently": "^9.2.0", "open": "^10.2.0", diff --git a/server/package.json b/server/package.json index 51fa6e42..04135574 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-server", - "version": "0.16.2", + "version": "0.16.3", "description": "Server-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)",