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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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 de16a18e2f8bd46682befe3227b44167c8e974c7 Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Tue, 5 Aug 2025 11:50:06 +0100
Subject: [PATCH 09/26] fix: use spread operator to avoid empty string
arguments
This fixes the regression introduced in dc5fa6a where empty strings
were passed as arguments when no command or args were provided,
causing parseArgs to fail with "Unexpected argument ''" error.
The spread operator approach is more idiomatic and avoids creating
empty strings that need to be filtered out.
---
client/bin/start.js | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/client/bin/start.js b/client/bin/start.js
index 0b5e84e2..e0496cde 100755
--- a/client/bin/start.js
+++ b/client/bin/start.js
@@ -91,10 +91,10 @@ async function startProdServer(serverOptions) {
"node",
[
inspectorServerPath,
- command ? `--command=${command}` : "",
- mcpServerArgs && mcpServerArgs.length > 0
- ? `--args=${mcpServerArgs.join(" ")}`
- : "",
+ ...(command ? [`--command=${command}`] : []),
+ ...(mcpServerArgs && mcpServerArgs.length > 0
+ ? [`--args=${mcpServerArgs.join(" ")}`]
+ : []),
],
{
env: {
From acf6c1e118e3b117e06a1a5ea17aeeba3bf736de Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Wed, 30 Jul 2025 16:37:59 +0100
Subject: [PATCH 10/26] 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 11/26] 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 12/26] 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 13/26] 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 14/26] 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 15/26] 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 16/26] 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 17/26] 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 18/26] 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 19/26] 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 20/26] 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 21/26] 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 22/26] 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 23/26] 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 24/26] 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 25/26] 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 26/26] 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)",