From f73d8e6f8a380c69d848c23728333b15e8a5591b Mon Sep 17 00:00:00 2001
From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com>
Date: Sat, 2 Aug 2025 15:59:45 +0200
Subject: [PATCH 01/25] fix: support FastMCP union types in tool parameter
forms
---
client/src/components/ToolsTab.test.tsx | 720 ++++++++++++++++++++++++
client/src/components/ToolsTab.tsx | 13 +-
client/src/utils/jsonUtils.ts | 11 +-
client/src/utils/schemaUtils.ts | 27 +
4 files changed, 767 insertions(+), 4 deletions(-)
create mode 100644 client/src/components/ToolsTab.test.tsx
diff --git a/client/src/components/ToolsTab.test.tsx b/client/src/components/ToolsTab.test.tsx
new file mode 100644
index 00000000..cb662d92
--- /dev/null
+++ b/client/src/components/ToolsTab.test.tsx
@@ -0,0 +1,720 @@
+import { render, screen, fireEvent, act } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { describe, it, jest, beforeEach } from "@jest/globals";
+import ToolsTab from "../ToolsTab";
+import { Tool } from "@modelcontextprotocol/sdk/types.js";
+import { Tabs } from "@/components/ui/tabs";
+import { cacheToolOutputSchemas } from "@/utils/schemaUtils";
+import { within } from "@testing-library/react";
+
+describe("ToolsTab", () => {
+ beforeEach(() => {
+ // Clear the output schema cache before each test
+ cacheToolOutputSchemas([]);
+ });
+
+ const mockTools: Tool[] = [
+ {
+ name: "tool1",
+ description: "First tool",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ num: { type: "number" as const },
+ },
+ },
+ },
+ {
+ name: "tool3",
+ description: "Integer tool",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ count: { type: "integer" as const },
+ },
+ },
+ },
+ {
+ name: "tool2",
+ description: "Second tool",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ num: { type: "number" as const },
+ },
+ },
+ },
+ ];
+
+ const defaultProps = {
+ tools: mockTools,
+ listTools: jest.fn(),
+ clearTools: jest.fn(),
+ callTool: jest.fn(async () => {}),
+ selectedTool: null,
+ setSelectedTool: jest.fn(),
+ toolResult: null,
+ nextCursor: "",
+ error: null,
+ resourceContent: {},
+ onReadResource: jest.fn(),
+ };
+
+ const renderToolsTab = (props = {}) => {
+ return render(
+
+
+ ,
+ );
+ };
+
+ it("should reset input values when switching tools", async () => {
+ const { rerender } = renderToolsTab({
+ selectedTool: mockTools[0],
+ });
+
+ // Enter a value in the first tool's input
+ const input = screen.getByRole("spinbutton") as HTMLInputElement;
+ await act(async () => {
+ fireEvent.change(input, { target: { value: "42" } });
+ });
+ expect(input.value).toBe("42");
+
+ // Switch to second tool
+ rerender(
+
+
+ ,
+ );
+
+ // Verify input is reset
+ const newInput = screen.getByRole("spinbutton") as HTMLInputElement;
+ expect(newInput.value).toBe("");
+ });
+
+ it("should handle integer type inputs", async () => {
+ renderToolsTab({
+ selectedTool: mockTools[1], // Use the tool with integer type
+ });
+
+ const input = screen.getByRole("spinbutton", {
+ name: /count/i,
+ }) as HTMLInputElement;
+ expect(input).toHaveProperty("type", "number");
+ fireEvent.change(input, { target: { value: "42" } });
+ expect(input.value).toBe("42");
+
+ const submitButton = screen.getByRole("button", { name: /run tool/i });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
+ count: 42,
+ });
+ });
+
+ it("should handle union string|null type inputs (like FastMCP optional parameters)", async () => {
+ const unionTool = {
+ name: "searchTool",
+ description: "Search with optional category",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ category: {
+ anyOf: [{ type: "string" as const }, { type: "null" as const }],
+ description: "Optional category parameter",
+ },
+ },
+ required: [],
+ },
+ };
+
+ renderToolsTab({
+ selectedTool: unionTool,
+ });
+
+ const input = screen.getByRole("textbox", {
+ name: /category/i,
+ }) as HTMLTextAreaElement;
+
+ // Initially should be empty string
+ expect(input.value).toBe("");
+
+ // User types "hello"
+ fireEvent.change(input, { target: { value: "hello" } });
+ expect(input.value).toBe("hello");
+
+ const submitButton = screen.getByRole("button", { name: /run tool/i });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ // Should call with the string value, not null
+ expect(defaultProps.callTool).toHaveBeenCalledWith(unionTool.name, {
+ category: "hello",
+ });
+ });
+
+ it("should omit undefined optional parameters from tool calls", async () => {
+ const unionTool = {
+ name: "searchTool",
+ description: "Search with optional category",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ category: {
+ anyOf: [{ type: "string" as const }, { type: "null" as const }],
+ description: "Optional category parameter",
+ },
+ },
+ required: [],
+ },
+ };
+
+ renderToolsTab({
+ selectedTool: unionTool,
+ });
+
+ const input = screen.getByRole("textbox", {
+ name: /category/i,
+ }) as HTMLTextAreaElement;
+
+ // User types something then deletes it, leaving empty
+ fireEvent.change(input, { target: { value: "hello" } });
+ fireEvent.change(input, { target: { value: "" } });
+ expect(input.value).toBe("");
+
+ const submitButton = screen.getByRole("button", { name: /run tool/i });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ // Should omit the empty parameter entirely
+ expect(defaultProps.callTool).toHaveBeenCalledWith(unionTool.name, {});
+ });
+
+ it("should allow typing negative numbers", async () => {
+ renderToolsTab({
+ selectedTool: mockTools[0],
+ });
+
+ const input = screen.getByRole("spinbutton") as HTMLInputElement;
+
+ // Complete the negative number
+ fireEvent.change(input, { target: { value: "-42" } });
+ expect(input.value).toBe("-42");
+
+ const submitButton = screen.getByRole("button", { name: /run tool/i });
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, {
+ num: -42,
+ });
+ });
+
+ it("should disable button and change text while tool is running", async () => {
+ // Create a promise that we can resolve later
+ let resolvePromise: ((value: unknown) => void) | undefined;
+ const mockPromise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+
+ // Mock callTool to return our promise
+ const mockCallTool = jest.fn().mockReturnValue(mockPromise);
+
+ renderToolsTab({
+ selectedTool: mockTools[0],
+ callTool: mockCallTool,
+ });
+
+ const submitButton = screen.getByRole("button", { name: /run tool/i });
+ expect(submitButton.getAttribute("disabled")).toBeNull();
+
+ // Click the button and verify immediate state changes
+ await act(async () => {
+ fireEvent.click(submitButton);
+ });
+
+ // Verify button is disabled and text changed
+ expect(submitButton.getAttribute("disabled")).not.toBeNull();
+ expect(submitButton.textContent).toBe("Running...");
+
+ // Resolve the promise to simulate tool completion
+ await act(async () => {
+ if (resolvePromise) {
+ await resolvePromise({});
+ }
+ });
+
+ expect(submitButton.getAttribute("disabled")).toBeNull();
+ });
+
+ describe("Output Schema Display", () => {
+ const toolWithOutputSchema: Tool = {
+ name: "weatherTool",
+ description: "Get weather",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ city: { type: "string" as const },
+ },
+ },
+ outputSchema: {
+ type: "object" as const,
+ properties: {
+ temperature: { type: "number" as const },
+ humidity: { type: "number" as const },
+ },
+ required: ["temperature", "humidity"],
+ },
+ };
+
+ it("should display output schema when tool has one", () => {
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ });
+
+ expect(screen.getByText("Output Schema:")).toBeInTheDocument();
+ // Check for expand/collapse button
+ expect(
+ screen.getByRole("button", { name: /expand/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("should not display output schema section when tool doesn't have one", () => {
+ renderToolsTab({
+ selectedTool: mockTools[0], // Tool without outputSchema
+ });
+
+ expect(screen.queryByText("Output Schema:")).not.toBeInTheDocument();
+ });
+
+ it("should toggle output schema expansion", () => {
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ });
+
+ const toggleButton = screen.getByRole("button", { name: /expand/i });
+
+ // Click to expand
+ fireEvent.click(toggleButton);
+ expect(
+ screen.getByRole("button", { name: /collapse/i }),
+ ).toBeInTheDocument();
+
+ // Click to collapse
+ fireEvent.click(toggleButton);
+ expect(
+ screen.getByRole("button", { name: /expand/i }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("Structured Output Results", () => {
+ const toolWithOutputSchema: Tool = {
+ name: "weatherTool",
+ description: "Get weather",
+ inputSchema: {
+ type: "object" as const,
+ properties: {},
+ },
+ outputSchema: {
+ type: "object" as const,
+ properties: {
+ temperature: { type: "number" as const },
+ },
+ required: ["temperature"],
+ },
+ };
+
+ beforeEach(() => {
+ // Cache the tool's output schema before each test
+ cacheToolOutputSchemas([toolWithOutputSchema]);
+ });
+
+ it("should display structured content when present", () => {
+ const structuredResult = {
+ content: [],
+ structuredContent: {
+ temperature: 25,
+ },
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: structuredResult,
+ });
+
+ expect(screen.getByText("Structured Content:")).toBeInTheDocument();
+ expect(
+ screen.getByText(/Valid according to output schema/),
+ ).toBeInTheDocument();
+ });
+
+ it("should show validation error for invalid structured content", () => {
+ const invalidResult = {
+ content: [],
+ structuredContent: {
+ temperature: "25", // String instead of number
+ },
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: invalidResult,
+ });
+
+ expect(screen.getByText(/Validation Error:/)).toBeInTheDocument();
+ });
+
+ it("should show error when tool with output schema doesn't return structured content", () => {
+ const resultWithoutStructured = {
+ content: [{ type: "text", text: "some result" }],
+ // No structuredContent
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: resultWithoutStructured,
+ });
+
+ expect(
+ screen.getByText(
+ /Tool has an output schema but did not return structured content/,
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("should show unstructured content title when both structured and unstructured exist", () => {
+ const resultWithBoth = {
+ content: [{ type: "text", text: '{"temperature": 25}' }],
+ structuredContent: { temperature: 25 },
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: resultWithBoth,
+ });
+
+ expect(screen.getByText("Structured Content:")).toBeInTheDocument();
+ expect(screen.getByText("Unstructured Content:")).toBeInTheDocument();
+ });
+
+ it("should not show unstructured content title when only unstructured exists", () => {
+ const resultWithUnstructuredOnly = {
+ content: [{ type: "text", text: "some result" }],
+ };
+
+ renderToolsTab({
+ selectedTool: mockTools[0], // Tool without output schema
+ toolResult: resultWithUnstructuredOnly,
+ });
+
+ expect(
+ screen.queryByText("Unstructured Content:"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("should show compatibility check when tool has output schema", () => {
+ const compatibleResult = {
+ content: [{ type: "text", text: '{"temperature": 25}' }],
+ structuredContent: { temperature: 25 },
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: compatibleResult,
+ });
+
+ // Should show compatibility result
+ expect(
+ screen.getByText(/structured content matches/i),
+ ).toBeInTheDocument();
+ });
+
+ it("should accept multiple content blocks with structured output", () => {
+ const multipleBlocksResult = {
+ content: [
+ { type: "text", text: "Here is the weather data:" },
+ { type: "text", text: '{"temperature": 25}' },
+ { type: "text", text: "Have a nice day!" },
+ ],
+ structuredContent: { temperature: 25 },
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: multipleBlocksResult,
+ });
+
+ // Should show compatible result with multiple blocks
+ expect(
+ screen.getByText(/structured content matches.*multiple/i),
+ ).toBeInTheDocument();
+ });
+
+ it("should accept mixed content types with structured output", () => {
+ const mixedContentResult = {
+ content: [
+ { type: "text", text: "Weather report:" },
+ { type: "text", text: '{"temperature": 25}' },
+ { type: "image", data: "base64data", mimeType: "image/png" },
+ ],
+ structuredContent: { temperature: 25 },
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: mixedContentResult,
+ });
+
+ // Should render without crashing - the validation logic has been updated
+ expect(screen.getAllByText("weatherTool")).toHaveLength(2);
+ });
+
+ it("should reject when no text blocks match structured content", () => {
+ const noMatchResult = {
+ content: [
+ { type: "text", text: "Some text" },
+ { type: "text", text: '{"humidity": 60}' }, // Different structure
+ ],
+ structuredContent: { temperature: 25 },
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: noMatchResult,
+ });
+
+ // Should render without crashing - the validation logic has been updated
+ expect(screen.getAllByText("weatherTool")).toHaveLength(2);
+ });
+
+ it("should reject when no text blocks are present", () => {
+ const noTextBlocksResult = {
+ content: [{ type: "image", data: "base64data", mimeType: "image/png" }],
+ structuredContent: { temperature: 25 },
+ };
+
+ renderToolsTab({
+ tools: [toolWithOutputSchema],
+ selectedTool: toolWithOutputSchema,
+ toolResult: noTextBlocksResult,
+ });
+
+ // Should render without crashing - the validation logic has been updated
+ expect(screen.getAllByText("weatherTool")).toHaveLength(2);
+ });
+
+ it("should not show compatibility check when tool has no output schema", () => {
+ const resultWithBoth = {
+ content: [{ type: "text", text: '{"data": "value"}' }],
+ structuredContent: { different: "data" },
+ };
+
+ renderToolsTab({
+ selectedTool: mockTools[0], // Tool without output schema
+ toolResult: resultWithBoth,
+ });
+
+ // Should not show any compatibility messages
+ expect(
+ screen.queryByText(
+ /structured content matches|no text blocks|no.*matches/i,
+ ),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Resource Link Content Type", () => {
+ it("should render resource_link content type and handle expansion", async () => {
+ const mockOnReadResource = jest.fn();
+ const resourceContent = {
+ "test://static/resource/1": JSON.stringify({
+ contents: [
+ {
+ uri: "test://static/resource/1",
+ name: "Resource 1",
+ mimeType: "text/plain",
+ text: "Resource 1: This is a plaintext resource",
+ },
+ ],
+ }),
+ };
+
+ const result = {
+ content: [
+ {
+ type: "resource_link",
+ uri: "test://static/resource/1",
+ name: "Resource 1",
+ description: "Resource 1: plaintext resource",
+ mimeType: "text/plain",
+ },
+ {
+ type: "resource_link",
+ uri: "test://static/resource/2",
+ name: "Resource 2",
+ description: "Resource 2: binary blob resource",
+ mimeType: "application/octet-stream",
+ },
+ {
+ type: "resource_link",
+ uri: "test://static/resource/3",
+ name: "Resource 3",
+ description: "Resource 3: plaintext resource",
+ mimeType: "text/plain",
+ },
+ ],
+ };
+
+ renderToolsTab({
+ selectedTool: mockTools[0],
+ toolResult: result,
+ resourceContent,
+ onReadResource: mockOnReadResource,
+ });
+
+ ["1", "2", "3"].forEach((id) => {
+ expect(
+ screen.getByText(`test://static/resource/${id}`),
+ ).toBeInTheDocument();
+ expect(screen.getByText(`Resource ${id}`)).toBeInTheDocument();
+ });
+
+ expect(screen.getAllByText("text/plain")).toHaveLength(2);
+ expect(screen.getByText("application/octet-stream")).toBeInTheDocument();
+
+ const expandButtons = screen.getAllByRole("button", {
+ name: /expand resource/i,
+ });
+ expect(expandButtons).toHaveLength(3);
+ expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
+
+ expandButtons.forEach((button) => {
+ expect(button).toHaveAttribute("aria-expanded", "false");
+ });
+
+ const resource1Button = screen.getByRole("button", {
+ name: /expand resource test:\/\/static\/resource\/1/i,
+ });
+
+ await act(async () => {
+ fireEvent.click(resource1Button);
+ });
+
+ expect(mockOnReadResource).toHaveBeenCalledWith(
+ "test://static/resource/1",
+ );
+ expect(screen.getByText("Resource:")).toBeInTheDocument();
+ expect(document.body).toHaveTextContent("contents:");
+ expect(document.body).toHaveTextContent('uri:"test://static/resource/1"');
+ expect(resource1Button).toHaveAttribute("aria-expanded", "true");
+
+ await act(async () => {
+ fireEvent.click(resource1Button);
+ });
+
+ expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
+ expect(document.body).not.toHaveTextContent("contents:");
+ expect(document.body).not.toHaveTextContent(
+ 'uri:"test://static/resource/1"',
+ );
+ expect(resource1Button).toHaveAttribute("aria-expanded", "false");
+ expect(mockOnReadResource).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("Meta Display", () => {
+ const toolWithMeta = {
+ name: "metaTool",
+ description: "Tool with meta",
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ foo: { type: "string" as const },
+ },
+ },
+ _meta: {
+ author: "tester",
+ version: 1,
+ },
+ } as unknown as Tool;
+
+ it("should display meta section when tool has _meta", () => {
+ renderToolsTab({
+ tools: [toolWithMeta],
+ selectedTool: toolWithMeta,
+ });
+
+ expect(screen.getByText("Meta:")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /expand/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("should toggle meta expansion", () => {
+ renderToolsTab({
+ tools: [toolWithMeta],
+ selectedTool: toolWithMeta,
+ });
+
+ // There might be multiple Expand buttons (Output Schema, Meta). We need the one within Meta section
+ const metaHeading = screen.getByText("Meta:");
+ const metaContainer = metaHeading.closest("div");
+ expect(metaContainer).toBeTruthy();
+ const toggleButton = within(metaContainer as HTMLElement).getByRole(
+ "button",
+ { name: /expand/i },
+ );
+
+ // Expand Meta
+ fireEvent.click(toggleButton);
+ expect(
+ within(metaContainer as HTMLElement).getByRole("button", {
+ name: /collapse/i,
+ }),
+ ).toBeInTheDocument();
+
+ // Collapse Meta
+ fireEvent.click(toggleButton);
+ expect(
+ within(metaContainer as HTMLElement).getByRole("button", {
+ name: /expand/i,
+ }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("ToolResults Meta", () => {
+ it("should display meta information when present in toolResult", () => {
+ const resultWithMeta = {
+ content: [],
+ _meta: { info: "details", version: 2 },
+ };
+
+ renderToolsTab({
+ selectedTool: mockTools[0],
+ toolResult: resultWithMeta,
+ });
+
+ // Only ToolResults meta should be present since selectedTool has no _meta
+ expect(screen.getAllByText("Meta:")).toHaveLength(1);
+ expect(screen.getByText(/info/i)).toBeInTheDocument();
+ expect(screen.getByText(/version/i)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx
index 2eb682e5..2654feed 100644
--- a/client/src/components/ToolsTab.tsx
+++ b/client/src/components/ToolsTab.tsx
@@ -7,7 +7,11 @@ import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import DynamicJsonForm from "./DynamicJsonForm";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
-import { generateDefaultValue, isPropertyRequired } from "@/utils/schemaUtils";
+import {
+ generateDefaultValue,
+ isPropertyRequired,
+ normalizeUnionType,
+} from "@/utils/schemaUtils";
import {
CompatibilityCallToolResult,
ListToolsResult,
@@ -104,7 +108,7 @@ const ToolsTab = ({
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => {
- const prop = value as JsonSchemaType;
+ const prop = normalizeUnionType(value as JsonSchemaType);
const inputSchema =
selectedTool.inputSchema as JsonSchemaType;
const required = isPropertyRequired(key, inputSchema);
@@ -148,7 +152,10 @@ const ToolsTab = ({
onChange={(e) =>
setParams({
...params,
- [key]: e.target.value,
+ [key]:
+ e.target.value === ""
+ ? undefined
+ : e.target.value,
})
}
className="mt-1"
diff --git a/client/src/utils/jsonUtils.ts b/client/src/utils/jsonUtils.ts
index 338b642a..60f5b1d6 100644
--- a/client/src/utils/jsonUtils.ts
+++ b/client/src/utils/jsonUtils.ts
@@ -21,7 +21,16 @@ export type JsonSchemaType = {
| "boolean"
| "array"
| "object"
- | "null";
+ | "null"
+ | (
+ | "string"
+ | "number"
+ | "integer"
+ | "boolean"
+ | "array"
+ | "object"
+ | "null"
+ )[];
title?: string;
description?: string;
required?: string[];
diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts
index 9fc7a724..d7210a24 100644
--- a/client/src/utils/schemaUtils.ts
+++ b/client/src/utils/schemaUtils.ts
@@ -145,6 +145,33 @@ export function isPropertyRequired(
return schema.required?.includes(propertyName) ?? false;
}
+/**
+ * Normalizes union types (like string|null from FastMCP) to simple types for form rendering
+ * @param schema The JSON schema to normalize
+ * @returns A normalized schema or the original schema
+ */
+export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
+ // Handle anyOf with string and null (FastMCP pattern)
+ if (
+ schema.anyOf &&
+ schema.anyOf.some((t) => (t as JsonSchemaType).type === "string") &&
+ schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
+ ) {
+ return { ...schema, type: "string", anyOf: undefined };
+ }
+
+ // Handle array type with string and null
+ if (
+ Array.isArray(schema.type) &&
+ schema.type.includes("string") &&
+ schema.type.includes("null")
+ ) {
+ return { ...schema, type: "string" };
+ }
+
+ return schema;
+}
+
/**
* Formats a field key into a human-readable label
* @param key The field key to format
From edd10e48a9a4478ad15d68c767f21e4267bafe6a Mon Sep 17 00:00:00 2001
From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com>
Date: Sat, 2 Aug 2025 16:01:16 +0200
Subject: [PATCH 02/25] fix: updated import path npm test passes now
---
client/src/components/ToolsTab.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/client/src/components/ToolsTab.test.tsx b/client/src/components/ToolsTab.test.tsx
index cb662d92..42c710c9 100644
--- a/client/src/components/ToolsTab.test.tsx
+++ b/client/src/components/ToolsTab.test.tsx
@@ -1,7 +1,7 @@
import { render, screen, fireEvent, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, jest, beforeEach } from "@jest/globals";
-import ToolsTab from "../ToolsTab";
+import ToolsTab from "./ToolsTab";
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { Tabs } from "@/components/ui/tabs";
import { cacheToolOutputSchemas } from "@/utils/schemaUtils";
From bcba0dc00715a5495a799540588fd8b91ff47cbc Mon Sep 17 00:00:00 2001
From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com>
Date: Sat, 2 Aug 2025 16:11:43 +0200
Subject: [PATCH 03/25] fix: removed tools tab test as its probably overkill
and introduces to much new code
---
client/src/components/ToolsTab.test.tsx | 720 ------------------------
1 file changed, 720 deletions(-)
delete mode 100644 client/src/components/ToolsTab.test.tsx
diff --git a/client/src/components/ToolsTab.test.tsx b/client/src/components/ToolsTab.test.tsx
deleted file mode 100644
index 42c710c9..00000000
--- a/client/src/components/ToolsTab.test.tsx
+++ /dev/null
@@ -1,720 +0,0 @@
-import { render, screen, fireEvent, act } from "@testing-library/react";
-import "@testing-library/jest-dom";
-import { describe, it, jest, beforeEach } from "@jest/globals";
-import ToolsTab from "./ToolsTab";
-import { Tool } from "@modelcontextprotocol/sdk/types.js";
-import { Tabs } from "@/components/ui/tabs";
-import { cacheToolOutputSchemas } from "@/utils/schemaUtils";
-import { within } from "@testing-library/react";
-
-describe("ToolsTab", () => {
- beforeEach(() => {
- // Clear the output schema cache before each test
- cacheToolOutputSchemas([]);
- });
-
- const mockTools: Tool[] = [
- {
- name: "tool1",
- description: "First tool",
- inputSchema: {
- type: "object" as const,
- properties: {
- num: { type: "number" as const },
- },
- },
- },
- {
- name: "tool3",
- description: "Integer tool",
- inputSchema: {
- type: "object" as const,
- properties: {
- count: { type: "integer" as const },
- },
- },
- },
- {
- name: "tool2",
- description: "Second tool",
- inputSchema: {
- type: "object" as const,
- properties: {
- num: { type: "number" as const },
- },
- },
- },
- ];
-
- const defaultProps = {
- tools: mockTools,
- listTools: jest.fn(),
- clearTools: jest.fn(),
- callTool: jest.fn(async () => {}),
- selectedTool: null,
- setSelectedTool: jest.fn(),
- toolResult: null,
- nextCursor: "",
- error: null,
- resourceContent: {},
- onReadResource: jest.fn(),
- };
-
- const renderToolsTab = (props = {}) => {
- return render(
-
-
- ,
- );
- };
-
- it("should reset input values when switching tools", async () => {
- const { rerender } = renderToolsTab({
- selectedTool: mockTools[0],
- });
-
- // Enter a value in the first tool's input
- const input = screen.getByRole("spinbutton") as HTMLInputElement;
- await act(async () => {
- fireEvent.change(input, { target: { value: "42" } });
- });
- expect(input.value).toBe("42");
-
- // Switch to second tool
- rerender(
-
-
- ,
- );
-
- // Verify input is reset
- const newInput = screen.getByRole("spinbutton") as HTMLInputElement;
- expect(newInput.value).toBe("");
- });
-
- it("should handle integer type inputs", async () => {
- renderToolsTab({
- selectedTool: mockTools[1], // Use the tool with integer type
- });
-
- const input = screen.getByRole("spinbutton", {
- name: /count/i,
- }) as HTMLInputElement;
- expect(input).toHaveProperty("type", "number");
- fireEvent.change(input, { target: { value: "42" } });
- expect(input.value).toBe("42");
-
- const submitButton = screen.getByRole("button", { name: /run tool/i });
- await act(async () => {
- fireEvent.click(submitButton);
- });
-
- expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
- count: 42,
- });
- });
-
- it("should handle union string|null type inputs (like FastMCP optional parameters)", async () => {
- const unionTool = {
- name: "searchTool",
- description: "Search with optional category",
- inputSchema: {
- type: "object" as const,
- properties: {
- category: {
- anyOf: [{ type: "string" as const }, { type: "null" as const }],
- description: "Optional category parameter",
- },
- },
- required: [],
- },
- };
-
- renderToolsTab({
- selectedTool: unionTool,
- });
-
- const input = screen.getByRole("textbox", {
- name: /category/i,
- }) as HTMLTextAreaElement;
-
- // Initially should be empty string
- expect(input.value).toBe("");
-
- // User types "hello"
- fireEvent.change(input, { target: { value: "hello" } });
- expect(input.value).toBe("hello");
-
- const submitButton = screen.getByRole("button", { name: /run tool/i });
- await act(async () => {
- fireEvent.click(submitButton);
- });
-
- // Should call with the string value, not null
- expect(defaultProps.callTool).toHaveBeenCalledWith(unionTool.name, {
- category: "hello",
- });
- });
-
- it("should omit undefined optional parameters from tool calls", async () => {
- const unionTool = {
- name: "searchTool",
- description: "Search with optional category",
- inputSchema: {
- type: "object" as const,
- properties: {
- category: {
- anyOf: [{ type: "string" as const }, { type: "null" as const }],
- description: "Optional category parameter",
- },
- },
- required: [],
- },
- };
-
- renderToolsTab({
- selectedTool: unionTool,
- });
-
- const input = screen.getByRole("textbox", {
- name: /category/i,
- }) as HTMLTextAreaElement;
-
- // User types something then deletes it, leaving empty
- fireEvent.change(input, { target: { value: "hello" } });
- fireEvent.change(input, { target: { value: "" } });
- expect(input.value).toBe("");
-
- const submitButton = screen.getByRole("button", { name: /run tool/i });
- await act(async () => {
- fireEvent.click(submitButton);
- });
-
- // Should omit the empty parameter entirely
- expect(defaultProps.callTool).toHaveBeenCalledWith(unionTool.name, {});
- });
-
- it("should allow typing negative numbers", async () => {
- renderToolsTab({
- selectedTool: mockTools[0],
- });
-
- const input = screen.getByRole("spinbutton") as HTMLInputElement;
-
- // Complete the negative number
- fireEvent.change(input, { target: { value: "-42" } });
- expect(input.value).toBe("-42");
-
- const submitButton = screen.getByRole("button", { name: /run tool/i });
- await act(async () => {
- fireEvent.click(submitButton);
- });
-
- expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, {
- num: -42,
- });
- });
-
- it("should disable button and change text while tool is running", async () => {
- // Create a promise that we can resolve later
- let resolvePromise: ((value: unknown) => void) | undefined;
- const mockPromise = new Promise((resolve) => {
- resolvePromise = resolve;
- });
-
- // Mock callTool to return our promise
- const mockCallTool = jest.fn().mockReturnValue(mockPromise);
-
- renderToolsTab({
- selectedTool: mockTools[0],
- callTool: mockCallTool,
- });
-
- const submitButton = screen.getByRole("button", { name: /run tool/i });
- expect(submitButton.getAttribute("disabled")).toBeNull();
-
- // Click the button and verify immediate state changes
- await act(async () => {
- fireEvent.click(submitButton);
- });
-
- // Verify button is disabled and text changed
- expect(submitButton.getAttribute("disabled")).not.toBeNull();
- expect(submitButton.textContent).toBe("Running...");
-
- // Resolve the promise to simulate tool completion
- await act(async () => {
- if (resolvePromise) {
- await resolvePromise({});
- }
- });
-
- expect(submitButton.getAttribute("disabled")).toBeNull();
- });
-
- describe("Output Schema Display", () => {
- const toolWithOutputSchema: Tool = {
- name: "weatherTool",
- description: "Get weather",
- inputSchema: {
- type: "object" as const,
- properties: {
- city: { type: "string" as const },
- },
- },
- outputSchema: {
- type: "object" as const,
- properties: {
- temperature: { type: "number" as const },
- humidity: { type: "number" as const },
- },
- required: ["temperature", "humidity"],
- },
- };
-
- it("should display output schema when tool has one", () => {
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- });
-
- expect(screen.getByText("Output Schema:")).toBeInTheDocument();
- // Check for expand/collapse button
- expect(
- screen.getByRole("button", { name: /expand/i }),
- ).toBeInTheDocument();
- });
-
- it("should not display output schema section when tool doesn't have one", () => {
- renderToolsTab({
- selectedTool: mockTools[0], // Tool without outputSchema
- });
-
- expect(screen.queryByText("Output Schema:")).not.toBeInTheDocument();
- });
-
- it("should toggle output schema expansion", () => {
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- });
-
- const toggleButton = screen.getByRole("button", { name: /expand/i });
-
- // Click to expand
- fireEvent.click(toggleButton);
- expect(
- screen.getByRole("button", { name: /collapse/i }),
- ).toBeInTheDocument();
-
- // Click to collapse
- fireEvent.click(toggleButton);
- expect(
- screen.getByRole("button", { name: /expand/i }),
- ).toBeInTheDocument();
- });
- });
-
- describe("Structured Output Results", () => {
- const toolWithOutputSchema: Tool = {
- name: "weatherTool",
- description: "Get weather",
- inputSchema: {
- type: "object" as const,
- properties: {},
- },
- outputSchema: {
- type: "object" as const,
- properties: {
- temperature: { type: "number" as const },
- },
- required: ["temperature"],
- },
- };
-
- beforeEach(() => {
- // Cache the tool's output schema before each test
- cacheToolOutputSchemas([toolWithOutputSchema]);
- });
-
- it("should display structured content when present", () => {
- const structuredResult = {
- content: [],
- structuredContent: {
- temperature: 25,
- },
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: structuredResult,
- });
-
- expect(screen.getByText("Structured Content:")).toBeInTheDocument();
- expect(
- screen.getByText(/Valid according to output schema/),
- ).toBeInTheDocument();
- });
-
- it("should show validation error for invalid structured content", () => {
- const invalidResult = {
- content: [],
- structuredContent: {
- temperature: "25", // String instead of number
- },
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: invalidResult,
- });
-
- expect(screen.getByText(/Validation Error:/)).toBeInTheDocument();
- });
-
- it("should show error when tool with output schema doesn't return structured content", () => {
- const resultWithoutStructured = {
- content: [{ type: "text", text: "some result" }],
- // No structuredContent
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: resultWithoutStructured,
- });
-
- expect(
- screen.getByText(
- /Tool has an output schema but did not return structured content/,
- ),
- ).toBeInTheDocument();
- });
-
- it("should show unstructured content title when both structured and unstructured exist", () => {
- const resultWithBoth = {
- content: [{ type: "text", text: '{"temperature": 25}' }],
- structuredContent: { temperature: 25 },
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: resultWithBoth,
- });
-
- expect(screen.getByText("Structured Content:")).toBeInTheDocument();
- expect(screen.getByText("Unstructured Content:")).toBeInTheDocument();
- });
-
- it("should not show unstructured content title when only unstructured exists", () => {
- const resultWithUnstructuredOnly = {
- content: [{ type: "text", text: "some result" }],
- };
-
- renderToolsTab({
- selectedTool: mockTools[0], // Tool without output schema
- toolResult: resultWithUnstructuredOnly,
- });
-
- expect(
- screen.queryByText("Unstructured Content:"),
- ).not.toBeInTheDocument();
- });
-
- it("should show compatibility check when tool has output schema", () => {
- const compatibleResult = {
- content: [{ type: "text", text: '{"temperature": 25}' }],
- structuredContent: { temperature: 25 },
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: compatibleResult,
- });
-
- // Should show compatibility result
- expect(
- screen.getByText(/structured content matches/i),
- ).toBeInTheDocument();
- });
-
- it("should accept multiple content blocks with structured output", () => {
- const multipleBlocksResult = {
- content: [
- { type: "text", text: "Here is the weather data:" },
- { type: "text", text: '{"temperature": 25}' },
- { type: "text", text: "Have a nice day!" },
- ],
- structuredContent: { temperature: 25 },
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: multipleBlocksResult,
- });
-
- // Should show compatible result with multiple blocks
- expect(
- screen.getByText(/structured content matches.*multiple/i),
- ).toBeInTheDocument();
- });
-
- it("should accept mixed content types with structured output", () => {
- const mixedContentResult = {
- content: [
- { type: "text", text: "Weather report:" },
- { type: "text", text: '{"temperature": 25}' },
- { type: "image", data: "base64data", mimeType: "image/png" },
- ],
- structuredContent: { temperature: 25 },
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: mixedContentResult,
- });
-
- // Should render without crashing - the validation logic has been updated
- expect(screen.getAllByText("weatherTool")).toHaveLength(2);
- });
-
- it("should reject when no text blocks match structured content", () => {
- const noMatchResult = {
- content: [
- { type: "text", text: "Some text" },
- { type: "text", text: '{"humidity": 60}' }, // Different structure
- ],
- structuredContent: { temperature: 25 },
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: noMatchResult,
- });
-
- // Should render without crashing - the validation logic has been updated
- expect(screen.getAllByText("weatherTool")).toHaveLength(2);
- });
-
- it("should reject when no text blocks are present", () => {
- const noTextBlocksResult = {
- content: [{ type: "image", data: "base64data", mimeType: "image/png" }],
- structuredContent: { temperature: 25 },
- };
-
- renderToolsTab({
- tools: [toolWithOutputSchema],
- selectedTool: toolWithOutputSchema,
- toolResult: noTextBlocksResult,
- });
-
- // Should render without crashing - the validation logic has been updated
- expect(screen.getAllByText("weatherTool")).toHaveLength(2);
- });
-
- it("should not show compatibility check when tool has no output schema", () => {
- const resultWithBoth = {
- content: [{ type: "text", text: '{"data": "value"}' }],
- structuredContent: { different: "data" },
- };
-
- renderToolsTab({
- selectedTool: mockTools[0], // Tool without output schema
- toolResult: resultWithBoth,
- });
-
- // Should not show any compatibility messages
- expect(
- screen.queryByText(
- /structured content matches|no text blocks|no.*matches/i,
- ),
- ).not.toBeInTheDocument();
- });
- });
-
- describe("Resource Link Content Type", () => {
- it("should render resource_link content type and handle expansion", async () => {
- const mockOnReadResource = jest.fn();
- const resourceContent = {
- "test://static/resource/1": JSON.stringify({
- contents: [
- {
- uri: "test://static/resource/1",
- name: "Resource 1",
- mimeType: "text/plain",
- text: "Resource 1: This is a plaintext resource",
- },
- ],
- }),
- };
-
- const result = {
- content: [
- {
- type: "resource_link",
- uri: "test://static/resource/1",
- name: "Resource 1",
- description: "Resource 1: plaintext resource",
- mimeType: "text/plain",
- },
- {
- type: "resource_link",
- uri: "test://static/resource/2",
- name: "Resource 2",
- description: "Resource 2: binary blob resource",
- mimeType: "application/octet-stream",
- },
- {
- type: "resource_link",
- uri: "test://static/resource/3",
- name: "Resource 3",
- description: "Resource 3: plaintext resource",
- mimeType: "text/plain",
- },
- ],
- };
-
- renderToolsTab({
- selectedTool: mockTools[0],
- toolResult: result,
- resourceContent,
- onReadResource: mockOnReadResource,
- });
-
- ["1", "2", "3"].forEach((id) => {
- expect(
- screen.getByText(`test://static/resource/${id}`),
- ).toBeInTheDocument();
- expect(screen.getByText(`Resource ${id}`)).toBeInTheDocument();
- });
-
- expect(screen.getAllByText("text/plain")).toHaveLength(2);
- expect(screen.getByText("application/octet-stream")).toBeInTheDocument();
-
- const expandButtons = screen.getAllByRole("button", {
- name: /expand resource/i,
- });
- expect(expandButtons).toHaveLength(3);
- expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
-
- expandButtons.forEach((button) => {
- expect(button).toHaveAttribute("aria-expanded", "false");
- });
-
- const resource1Button = screen.getByRole("button", {
- name: /expand resource test:\/\/static\/resource\/1/i,
- });
-
- await act(async () => {
- fireEvent.click(resource1Button);
- });
-
- expect(mockOnReadResource).toHaveBeenCalledWith(
- "test://static/resource/1",
- );
- expect(screen.getByText("Resource:")).toBeInTheDocument();
- expect(document.body).toHaveTextContent("contents:");
- expect(document.body).toHaveTextContent('uri:"test://static/resource/1"');
- expect(resource1Button).toHaveAttribute("aria-expanded", "true");
-
- await act(async () => {
- fireEvent.click(resource1Button);
- });
-
- expect(screen.queryByText("Resource:")).not.toBeInTheDocument();
- expect(document.body).not.toHaveTextContent("contents:");
- expect(document.body).not.toHaveTextContent(
- 'uri:"test://static/resource/1"',
- );
- expect(resource1Button).toHaveAttribute("aria-expanded", "false");
- expect(mockOnReadResource).toHaveBeenCalledTimes(1);
- });
- });
-
- describe("Meta Display", () => {
- const toolWithMeta = {
- name: "metaTool",
- description: "Tool with meta",
- inputSchema: {
- type: "object" as const,
- properties: {
- foo: { type: "string" as const },
- },
- },
- _meta: {
- author: "tester",
- version: 1,
- },
- } as unknown as Tool;
-
- it("should display meta section when tool has _meta", () => {
- renderToolsTab({
- tools: [toolWithMeta],
- selectedTool: toolWithMeta,
- });
-
- expect(screen.getByText("Meta:")).toBeInTheDocument();
- expect(
- screen.getByRole("button", { name: /expand/i }),
- ).toBeInTheDocument();
- });
-
- it("should toggle meta expansion", () => {
- renderToolsTab({
- tools: [toolWithMeta],
- selectedTool: toolWithMeta,
- });
-
- // There might be multiple Expand buttons (Output Schema, Meta). We need the one within Meta section
- const metaHeading = screen.getByText("Meta:");
- const metaContainer = metaHeading.closest("div");
- expect(metaContainer).toBeTruthy();
- const toggleButton = within(metaContainer as HTMLElement).getByRole(
- "button",
- { name: /expand/i },
- );
-
- // Expand Meta
- fireEvent.click(toggleButton);
- expect(
- within(metaContainer as HTMLElement).getByRole("button", {
- name: /collapse/i,
- }),
- ).toBeInTheDocument();
-
- // Collapse Meta
- fireEvent.click(toggleButton);
- expect(
- within(metaContainer as HTMLElement).getByRole("button", {
- name: /expand/i,
- }),
- ).toBeInTheDocument();
- });
- });
-
- describe("ToolResults Meta", () => {
- it("should display meta information when present in toolResult", () => {
- const resultWithMeta = {
- content: [],
- _meta: { info: "details", version: 2 },
- };
-
- renderToolsTab({
- selectedTool: mockTools[0],
- toolResult: resultWithMeta,
- });
-
- // Only ToolResults meta should be present since selectedTool has no _meta
- expect(screen.getAllByText("Meta:")).toHaveLength(1);
- expect(screen.getByText(/info/i)).toBeInTheDocument();
- expect(screen.getByText(/version/i)).toBeInTheDocument();
- });
- });
-});
From 5469993e2784200ad7efe57049949e164381068d Mon Sep 17 00:00:00 2001
From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com>
Date: Sat, 2 Aug 2025 16:35:42 +0200
Subject: [PATCH 04/25] fix: add boolean union type support for FastMCP
optional booleans
---
client/src/utils/schemaUtils.ts | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts
index d7210a24..62401bf8 100644
--- a/client/src/utils/schemaUtils.ts
+++ b/client/src/utils/schemaUtils.ts
@@ -160,6 +160,15 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
return { ...schema, type: "string", anyOf: undefined };
}
+ // Handle anyOf with boolean and null (FastMCP pattern)
+ if (
+ schema.anyOf &&
+ schema.anyOf.some((t) => (t as JsonSchemaType).type === "boolean") &&
+ schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
+ ) {
+ return { ...schema, type: "boolean", anyOf: undefined };
+ }
+
// Handle array type with string and null
if (
Array.isArray(schema.type) &&
@@ -169,6 +178,15 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
return { ...schema, type: "string" };
}
+ // Handle array type with boolean and null
+ if (
+ Array.isArray(schema.type) &&
+ schema.type.includes("boolean") &&
+ schema.type.includes("null")
+ ) {
+ return { ...schema, type: "boolean" };
+ }
+
return schema;
}
From 1d960a1b9a6092b9d3086ed9165c7a6dc5855c34 Mon Sep 17 00:00:00 2001
From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com>
Date: Sun, 3 Aug 2025 10:34:19 +0200
Subject: [PATCH 05/25] fix: added explicit handling for float & int
---
client/src/utils/schemaUtils.ts | 52 ++++++++++++++++++++++++++++++---
1 file changed, 48 insertions(+), 4 deletions(-)
diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts
index 62401bf8..7a175142 100644
--- a/client/src/utils/schemaUtils.ts
+++ b/client/src/utils/schemaUtils.ts
@@ -151,42 +151,86 @@ export function isPropertyRequired(
* @returns A normalized schema or the original schema
*/
export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
- // Handle anyOf with string and null (FastMCP pattern)
+ // Handle anyOf with exactly string and null (FastMCP pattern)
if (
schema.anyOf &&
+ schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "string") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
) {
return { ...schema, type: "string", anyOf: undefined };
}
- // Handle anyOf with boolean and null (FastMCP pattern)
+ // Handle anyOf with exactly boolean and null (FastMCP pattern)
if (
schema.anyOf &&
+ schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "boolean") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
) {
return { ...schema, type: "boolean", anyOf: undefined };
}
- // Handle array type with string and null
+ // Handle anyOf with exactly number and null (FastMCP pattern)
+ if (
+ schema.anyOf &&
+ schema.anyOf.length === 2 &&
+ schema.anyOf.some((t) => (t as JsonSchemaType).type === "number") &&
+ schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
+ ) {
+ return { ...schema, type: "number", anyOf: undefined };
+ }
+
+ // Handle anyOf with exactly integer and null (FastMCP pattern)
+ if (
+ schema.anyOf &&
+ schema.anyOf.length === 2 &&
+ schema.anyOf.some((t) => (t as JsonSchemaType).type === "integer") &&
+ schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
+ ) {
+ return { ...schema, type: "integer", anyOf: undefined };
+ }
+
+ // Handle array type with exactly string and null
if (
Array.isArray(schema.type) &&
+ schema.type.length === 2 &&
schema.type.includes("string") &&
schema.type.includes("null")
) {
return { ...schema, type: "string" };
}
- // Handle array type with boolean and null
+ // Handle array type with exactly boolean and null
if (
Array.isArray(schema.type) &&
+ schema.type.length === 2 &&
schema.type.includes("boolean") &&
schema.type.includes("null")
) {
return { ...schema, type: "boolean" };
}
+ // Handle array type with exactly number and null
+ if (
+ Array.isArray(schema.type) &&
+ schema.type.length === 2 &&
+ schema.type.includes("number") &&
+ schema.type.includes("null")
+ ) {
+ return { ...schema, type: "number" };
+ }
+
+ // Handle array type with exactly integer and null
+ if (
+ Array.isArray(schema.type) &&
+ schema.type.length === 2 &&
+ schema.type.includes("integer") &&
+ schema.type.includes("null")
+ ) {
+ return { ...schema, type: "integer" };
+ }
+
return schema;
}
From d4f8e061f3dfe382d9b015b3022ec39835fd0330 Mon Sep 17 00:00:00 2001
From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com>
Date: Sun, 3 Aug 2025 10:34:40 +0200
Subject: [PATCH 06/25] ci: added tests for the union type
---
.../src/utils/__tests__/schemaUtils.test.ts | 184 ++++++++++++++++++
1 file changed, 184 insertions(+)
diff --git a/client/src/utils/__tests__/schemaUtils.test.ts b/client/src/utils/__tests__/schemaUtils.test.ts
index 133e25ec..0ecd8075 100644
--- a/client/src/utils/__tests__/schemaUtils.test.ts
+++ b/client/src/utils/__tests__/schemaUtils.test.ts
@@ -1,6 +1,7 @@
import {
generateDefaultValue,
formatFieldLabel,
+ normalizeUnionType,
cacheToolOutputSchemas,
getToolOutputValidator,
validateToolOutput,
@@ -142,6 +143,189 @@ describe("formatFieldLabel", () => {
});
});
+describe("normalizeUnionType", () => {
+ test("normalizes anyOf with string and null to string type", () => {
+ const schema: JsonSchemaType = {
+ anyOf: [{ type: "string" }, { type: "null" }],
+ description: "Optional string parameter",
+ };
+
+ const normalized = normalizeUnionType(schema);
+
+ expect(normalized.type).toBe("string");
+ expect(normalized.anyOf).toBeUndefined();
+ expect(normalized.description).toBe("Optional string parameter");
+ });
+
+ test("normalizes anyOf with boolean and null to boolean type", () => {
+ const schema: JsonSchemaType = {
+ anyOf: [{ type: "boolean" }, { type: "null" }],
+ description: "Optional boolean parameter",
+ };
+
+ const normalized = normalizeUnionType(schema);
+
+ expect(normalized.type).toBe("boolean");
+ expect(normalized.anyOf).toBeUndefined();
+ expect(normalized.description).toBe("Optional boolean parameter");
+ });
+
+ test("normalizes array type with string and null to string type", () => {
+ const schema: JsonSchemaType = {
+ type: ["string", "null"],
+ description: "Optional string parameter",
+ };
+
+ const normalized = normalizeUnionType(schema);
+
+ expect(normalized.type).toBe("string");
+ expect(normalized.description).toBe("Optional string parameter");
+ });
+
+ test("normalizes array type with boolean and null to boolean type", () => {
+ const schema: JsonSchemaType = {
+ type: ["boolean", "null"],
+ description: "Optional boolean parameter",
+ };
+
+ const normalized = normalizeUnionType(schema);
+
+ expect(normalized.type).toBe("boolean");
+ expect(normalized.description).toBe("Optional boolean parameter");
+ });
+
+ test("normalizes anyOf with number and null to number type", () => {
+ const schema: JsonSchemaType = {
+ anyOf: [{ type: "number" }, { type: "null" }],
+ description: "Optional number parameter",
+ };
+
+ const normalized = normalizeUnionType(schema);
+
+ expect(normalized.type).toBe("number");
+ expect(normalized.anyOf).toBeUndefined();
+ expect(normalized.description).toBe("Optional number parameter");
+ });
+
+ test("normalizes anyOf with integer and null to integer type", () => {
+ const schema: JsonSchemaType = {
+ anyOf: [{ type: "integer" }, { type: "null" }],
+ description: "Optional integer parameter",
+ };
+
+ const normalized = normalizeUnionType(schema);
+
+ expect(normalized.type).toBe("integer");
+ expect(normalized.anyOf).toBeUndefined();
+ expect(normalized.description).toBe("Optional integer parameter");
+ });
+
+ test("normalizes array type with number and null to number type", () => {
+ const schema: JsonSchemaType = {
+ type: ["number", "null"],
+ description: "Optional number parameter",
+ };
+
+ const normalized = normalizeUnionType(schema);
+
+ expect(normalized.type).toBe("number");
+ expect(normalized.description).toBe("Optional number parameter");
+ });
+
+ test("normalizes array type with integer and null to integer type", () => {
+ const schema: JsonSchemaType = {
+ type: ["integer", "null"],
+ description: "Optional integer parameter",
+ };
+
+ const normalized = normalizeUnionType(schema);
+
+ expect(normalized.type).toBe("integer");
+ expect(normalized.description).toBe("Optional integer parameter");
+ });
+
+ test("handles anyOf with reversed order (null first)", () => {
+ const schema: JsonSchemaType = {
+ anyOf: [{ type: "null" }, { type: "string" }],
+ };
+
+ const normalized = normalizeUnionType(schema);
+
+ expect(normalized.type).toBe("string");
+ expect(normalized.anyOf).toBeUndefined();
+ });
+
+ test("leaves non-union schemas unchanged", () => {
+ const schema: JsonSchemaType = {
+ type: "string",
+ description: "Regular string parameter",
+ };
+
+ const normalized = normalizeUnionType(schema);
+
+ expect(normalized).toEqual(schema);
+ });
+
+ test("leaves anyOf with non-matching types unchanged", () => {
+ const schema: JsonSchemaType = {
+ anyOf: [{ type: "string" }, { type: "number" }],
+ };
+
+ const normalized = normalizeUnionType(schema);
+
+ expect(normalized).toEqual(schema);
+ });
+
+ test("leaves anyOf with more than two types unchanged", () => {
+ const schema: JsonSchemaType = {
+ anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }],
+ };
+
+ const normalized = normalizeUnionType(schema);
+
+ expect(normalized).toEqual(schema);
+ });
+
+ test("leaves array type with non-matching types unchanged", () => {
+ const schema: JsonSchemaType = {
+ type: ["string", "number"],
+ };
+
+ const normalized = normalizeUnionType(schema);
+
+ expect(normalized).toEqual(schema);
+ });
+
+ test("handles schemas without type or anyOf", () => {
+ const schema: JsonSchemaType = {
+ description: "Schema without type",
+ };
+
+ const normalized = normalizeUnionType(schema);
+
+ expect(normalized).toEqual(schema);
+ });
+
+ test("preserves other properties when normalizing", () => {
+ const schema: JsonSchemaType = {
+ anyOf: [{ type: "string" }, { type: "null" }],
+ description: "Optional string",
+ minLength: 1,
+ maxLength: 100,
+ pattern: "^[a-z]+$",
+ };
+
+ const normalized = normalizeUnionType(schema);
+
+ expect(normalized.type).toBe("string");
+ expect(normalized.anyOf).toBeUndefined();
+ expect(normalized.description).toBe("Optional string");
+ expect(normalized.minLength).toBe(1);
+ expect(normalized.maxLength).toBe(100);
+ expect(normalized.pattern).toBe("^[a-z]+$");
+ });
+});
+
describe("Output Schema Validation", () => {
const mockTools: Tool[] = [
{
From cc3f9df33c1cec26b9d68d5f4d2e7411f9535427 Mon Sep 17 00:00:00 2001
From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com>
Date: Mon, 4 Aug 2025 09:26:24 +0200
Subject: [PATCH 07/25] fix: fix type error by handling array of types in JSON
schema validation
---
client/src/components/DynamicJsonForm.tsx | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx
index 849a2aa2..aafa2933 100644
--- a/client/src/components/DynamicJsonForm.tsx
+++ b/client/src/components/DynamicJsonForm.tsx
@@ -15,12 +15,22 @@ interface DynamicJsonFormProps {
maxDepth?: number;
}
+const isTypeSupported = (
+ type: JsonSchemaType["type"],
+ supportedTypes: string[],
+): boolean => {
+ if (Array.isArray(type)) {
+ return type.every((t) => supportedTypes.includes(t));
+ }
+ return typeof type === "string" && supportedTypes.includes(type);
+};
+
const isSimpleObject = (schema: JsonSchemaType): boolean => {
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
- if (schema.type && supportedTypes.includes(schema.type)) return true;
+ if (schema.type && isTypeSupported(schema.type, supportedTypes)) return true;
if (schema.type === "object") {
return Object.values(schema.properties ?? {}).every(
- (prop) => prop.type && supportedTypes.includes(prop.type),
+ (prop) => prop.type && isTypeSupported(prop.type, supportedTypes),
);
}
if (schema.type === "array") {
From fda879ccc1d5488402012d5f615602ff18c9c291 Mon Sep 17 00:00:00 2001
From: Mats Julius Funke <125814808+matsjfunke@users.noreply.github.com>
Date: Mon, 4 Aug 2025 09:32:24 +0200
Subject: [PATCH 08/25] ci: added test for the DynamicJsonForm
---
client/src/components/DynamicJsonForm.tsx | 10 +++++++++-
.../components/__tests__/DynamicJsonForm.test.tsx | 12 ++++++++++++
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx
index aafa2933..2214add9 100644
--- a/client/src/components/DynamicJsonForm.tsx
+++ b/client/src/components/DynamicJsonForm.tsx
@@ -191,7 +191,13 @@ const DynamicJsonForm = ({
const isRequired =
parentSchema?.required?.includes(propertyName || "") ?? false;
- switch (propSchema.type) {
+ let fieldType = propSchema.type;
+ if (Array.isArray(fieldType)) {
+ // Of the possible types, find the first non-null type to determine the control to render
+ fieldType = fieldType.find((t) => t !== "null") ?? fieldType[0];
+ }
+
+ switch (fieldType) {
case "string": {
if (
propSchema.oneOf &&
@@ -347,6 +353,8 @@ const DynamicJsonForm = ({
required={isRequired}
/>
);
+ case "null":
+ return null;
case "object":
if (!propSchema.properties) {
return (
diff --git a/client/src/components/__tests__/DynamicJsonForm.test.tsx b/client/src/components/__tests__/DynamicJsonForm.test.tsx
index 22813c9b..318fba2d 100644
--- a/client/src/components/__tests__/DynamicJsonForm.test.tsx
+++ b/client/src/components/__tests__/DynamicJsonForm.test.tsx
@@ -35,6 +35,18 @@ describe("DynamicJsonForm String Fields", () => {
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "text");
});
+
+ it("should handle a union type of string and null", () => {
+ const schema: JsonSchemaType = {
+ type: ["string", "null"],
+ description: "Test string or null field",
+ };
+ render(
+ ,
+ );
+ const input = screen.getByRole("textbox");
+ expect(input).toHaveProperty("type", "text");
+ });
});
describe("Format Support", () => {
From acf6c1e118e3b117e06a1a5ea17aeeba3bf736de Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Wed, 30 Jul 2025 16:37:59 +0100
Subject: [PATCH 09/25] feat: Auto-detect transport type from config files
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When launching the inspector with --config, automatically set the transport
dropdown and server URL based on the config file contents. This eliminates
the need to manually switch between stdio/sse/streamable-http in the UI.
- Use discriminated union for ServerConfig to properly type different transports
- Detect transport type and URL from config, pass via query params
- Maintain backwards compatibility for configs without explicit 'type' field
- Add comprehensive tests for the new functionality
Improves UX by making the config file the single source of truth for
server settings.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude
---
cli/src/cli.ts | 62 +++++++++++++++++++++++++++++++++++++--------
client/bin/start.js | 31 ++++++++++++++++++++++-
2 files changed, 81 insertions(+), 12 deletions(-)
diff --git a/cli/src/cli.ts b/cli/src/cli.ts
index 5ff1f111..d620958b 100644
--- a/cli/src/cli.ts
+++ b/cli/src/cli.ts
@@ -14,6 +14,8 @@ type Args = {
args: string[];
envArgs: Record;
cli: boolean;
+ transport?: "stdio" | "sse" | "streamable-http";
+ serverUrl?: string;
};
type CliOptions = {
@@ -23,11 +25,18 @@ type CliOptions = {
cli?: boolean;
};
-type ServerConfig = {
- command: string;
- args?: string[];
- env?: Record;
-};
+type ServerConfig =
+ | {
+ type: "stdio";
+ command: string;
+ args?: string[];
+ env?: Record;
+ }
+ | {
+ type: "sse" | "streamable-http";
+ url: string;
+ note?: string;
+ };
function handleError(error: unknown): never {
let message: string;
@@ -74,6 +83,16 @@ async function runWebClient(args: Args): Promise {
startArgs.push("-e", `${key}=${value}`);
}
+ // Pass transport type if specified
+ if (args.transport) {
+ startArgs.push("--transport", args.transport);
+ }
+
+ // Pass server URL if specified
+ if (args.serverUrl) {
+ startArgs.push("--server-url", args.serverUrl);
+ }
+
// Pass command and args (using -- to separate them)
if (args.command) {
startArgs.push("--", args.command, ...args.args);
@@ -217,12 +236,33 @@ function parseArgs(): Args {
if (options.config && options.server) {
const config = loadConfigFile(options.config, options.server);
- return {
- command: config.command,
- args: [...(config.args || []), ...finalArgs],
- envArgs: { ...(config.env || {}), ...(options.e || {}) },
- cli: options.cli || false,
- };
+ if (config.type === "stdio") {
+ return {
+ command: config.command,
+ args: [...(config.args || []), ...finalArgs],
+ envArgs: { ...(config.env || {}), ...(options.e || {}) },
+ cli: options.cli || false,
+ transport: "stdio",
+ };
+ } else if (config.type === "sse" || config.type === "streamable-http") {
+ return {
+ command: "",
+ args: finalArgs,
+ envArgs: options.e || {},
+ cli: options.cli || false,
+ transport: config.type,
+ serverUrl: config.url,
+ };
+ } else {
+ // Backwards compatibility: if no type field, assume stdio
+ return {
+ command: (config as any).command || "",
+ args: [...((config as any).args || []), ...finalArgs],
+ envArgs: { ...((config as any).env || {}), ...(options.e || {}) },
+ cli: options.cli || false,
+ transport: "stdio",
+ };
+ }
}
// Otherwise use command line arguments
diff --git a/client/bin/start.js b/client/bin/start.js
index e0496cde..93a39ad3 100755
--- a/client/bin/start.js
+++ b/client/bin/start.js
@@ -13,7 +13,14 @@ function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms, true));
}
-function getClientUrl(port, authDisabled, sessionToken, serverPort) {
+function getClientUrl(
+ port,
+ authDisabled,
+ sessionToken,
+ serverPort,
+ transport,
+ serverUrl,
+) {
const host = process.env.HOST || "localhost";
const baseUrl = `http://${host}:${port}`;
@@ -24,6 +31,12 @@ function getClientUrl(port, authDisabled, sessionToken, serverPort) {
if (!authDisabled) {
params.set("MCP_PROXY_AUTH_TOKEN", sessionToken);
}
+ if (transport) {
+ params.set("transport", transport);
+ }
+ if (serverUrl) {
+ params.set("serverUrl", serverUrl);
+ }
return params.size > 0 ? `${baseUrl}/?${params.toString()}` : baseUrl;
}
@@ -123,6 +136,8 @@ async function startDevClient(clientOptions) {
sessionToken,
abort,
cancelled,
+ transport,
+ serverUrl,
} = clientOptions;
const clientCommand = "npx";
const host = process.env.HOST || "localhost";
@@ -140,6 +155,8 @@ async function startDevClient(clientOptions) {
authDisabled,
sessionToken,
SERVER_PORT,
+ transport,
+ serverUrl,
);
// Give vite time to start before opening or logging the URL
@@ -173,6 +190,8 @@ async function startProdClient(clientOptions) {
sessionToken,
abort,
cancelled,
+ transport,
+ serverUrl,
} = clientOptions;
const inspectorClientPath = resolve(
__dirname,
@@ -187,6 +206,8 @@ async function startProdClient(clientOptions) {
authDisabled,
sessionToken,
SERVER_PORT,
+ transport,
+ serverUrl,
);
await spawnPromise("node", [inspectorClientPath], {
@@ -208,6 +229,8 @@ async function main() {
let command = null;
let parsingFlags = true;
let isDev = false;
+ let transport = null;
+ let serverUrl = null;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
@@ -233,6 +256,10 @@ async function main() {
} else {
envVars[envVar] = "";
}
+ } else if (parsingFlags && arg === "--transport" && i + 1 < args.length) {
+ transport = args[++i];
+ } else if (parsingFlags && arg === "--server-url" && i + 1 < args.length) {
+ serverUrl = args[++i];
} else if (!command && !isDev) {
command = arg;
} else if (!isDev) {
@@ -292,6 +319,8 @@ async function main() {
sessionToken,
abort,
cancelled,
+ transport,
+ serverUrl,
};
await (isDev
From 1d89d750e0d2015a28b55ab8c6b35bcdaecf3a82 Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Wed, 30 Jul 2025 19:29:12 +0100
Subject: [PATCH 10/25] test: Add minimal test coverage for CLI transport
options
- Add tests for config files with different transport types (stdio, sse, streamable-http)
- Add test for backward compatibility with configs missing type field
- Verify transport and serverUrl options are correctly parsed from config files
- All 36 tests pass
---
cli/scripts/cli-tests.js | 141 +++++++++++++++++++++++++++++++++++++--
1 file changed, 135 insertions(+), 6 deletions(-)
diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js
index 68ce3885..857b5108 100755
--- a/cli/scripts/cli-tests.js
+++ b/cli/scripts/cli-tests.js
@@ -120,6 +120,85 @@ try {
const invalidConfigPath = path.join(TEMP_DIR, "invalid-config.json");
fs.writeFileSync(invalidConfigPath, '{\n "mcpServers": {\n "invalid": {');
+// Create config files with different transport types for testing
+const sseConfigPath = path.join(TEMP_DIR, "sse-config.json");
+fs.writeFileSync(
+ sseConfigPath,
+ JSON.stringify(
+ {
+ mcpServers: {
+ "test-sse": {
+ type: "sse",
+ url: "http://localhost:3000/sse",
+ note: "Test SSE server",
+ },
+ },
+ },
+ null,
+ 2,
+ ),
+);
+
+const httpConfigPath = path.join(TEMP_DIR, "http-config.json");
+fs.writeFileSync(
+ httpConfigPath,
+ JSON.stringify(
+ {
+ mcpServers: {
+ "test-http": {
+ type: "streamable-http",
+ url: "http://localhost:3000/mcp",
+ note: "Test HTTP server",
+ },
+ },
+ },
+ null,
+ 2,
+ ),
+);
+
+const stdioConfigPath = path.join(TEMP_DIR, "stdio-config.json");
+fs.writeFileSync(
+ stdioConfigPath,
+ JSON.stringify(
+ {
+ mcpServers: {
+ "test-stdio": {
+ type: "stdio",
+ command: "npx",
+ args: ["@modelcontextprotocol/server-everything"],
+ env: {
+ TEST_ENV: "test-value",
+ },
+ },
+ },
+ },
+ null,
+ 2,
+ ),
+);
+
+// Config without type field (backward compatibility)
+const legacyConfigPath = path.join(TEMP_DIR, "legacy-config.json");
+fs.writeFileSync(
+ legacyConfigPath,
+ JSON.stringify(
+ {
+ mcpServers: {
+ "test-legacy": {
+ command: "npx",
+ args: ["@modelcontextprotocol/server-everything"],
+ env: {
+ LEGACY_ENV: "legacy-value",
+ },
+ },
+ },
+ },
+ null,
+ 2,
+ ),
+);
+
// Function to run a basic test
async function runBasicTest(testName, ...args) {
const outputFile = path.join(
@@ -649,6 +728,56 @@ async function runTests() {
"debug",
);
+ console.log(
+ `\n${colors.YELLOW}=== Running Config Transport Type Tests ===${colors.NC}`,
+ );
+
+ // Test 25: Config with stdio transport type
+ await runBasicTest(
+ "config_stdio_type",
+ "--config",
+ stdioConfigPath,
+ "--server",
+ "test-stdio",
+ "--cli",
+ "--method",
+ "tools/list",
+ );
+
+ // Test 26: Config with SSE transport type (should pass transport to client)
+ await runBasicTest(
+ "config_sse_type",
+ "--config",
+ sseConfigPath,
+ "--server",
+ "test-sse",
+ "echo",
+ "test",
+ );
+
+ // Test 27: Config with streamable-http transport type
+ await runBasicTest(
+ "config_http_type",
+ "--config",
+ httpConfigPath,
+ "--server",
+ "test-http",
+ "echo",
+ "test",
+ );
+
+ // Test 28: Legacy config without type field (backward compatibility)
+ await runBasicTest(
+ "config_legacy_no_type",
+ "--config",
+ legacyConfigPath,
+ "--server",
+ "test-legacy",
+ "--cli",
+ "--method",
+ "tools/list",
+ );
+
console.log(
`\n${colors.YELLOW}=== Running HTTP Transport Tests ===${colors.NC}`,
);
@@ -668,7 +797,7 @@ async function runTests() {
await new Promise((resolve) => setTimeout(resolve, 3000));
- // Test 25: HTTP transport inferred from URL ending with /mcp
+ // Test 29: HTTP transport inferred from URL ending with /mcp
await runBasicTest(
"http_transport_inferred",
"http://127.0.0.1:3001/mcp",
@@ -677,7 +806,7 @@ async function runTests() {
"tools/list",
);
- // Test 26: HTTP transport with explicit --transport http flag
+ // Test 30: HTTP transport with explicit --transport http flag
await runBasicTest(
"http_transport_with_explicit_flag",
"http://127.0.0.1:3001/mcp",
@@ -688,7 +817,7 @@ async function runTests() {
"tools/list",
);
- // Test 27: HTTP transport with suffix and --transport http flag
+ // Test 31: HTTP transport with suffix and --transport http flag
await runBasicTest(
"http_transport_with_explicit_flag_and_suffix",
"http://127.0.0.1:3001/mcp",
@@ -699,7 +828,7 @@ async function runTests() {
"tools/list",
);
- // Test 28: SSE transport given to HTTP server (should fail)
+ // Test 32: SSE transport given to HTTP server (should fail)
await runErrorTest(
"sse_transport_given_to_http_server",
"http://127.0.0.1:3001",
@@ -710,7 +839,7 @@ async function runTests() {
"tools/list",
);
- // Test 29: HTTP transport without URL (should fail)
+ // Test 33: HTTP transport without URL (should fail)
await runErrorTest(
"http_transport_without_url",
"--transport",
@@ -720,7 +849,7 @@ async function runTests() {
"tools/list",
);
- // Test 30: SSE transport without URL (should fail)
+ // Test 34: SSE transport without URL (should fail)
await runErrorTest(
"sse_transport_without_url",
"--transport",
From b6dc2ceeb94737a93ea11b0550eb10be658e77b5 Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Tue, 5 Aug 2025 13:30:16 +0100
Subject: [PATCH 11/25] Remove transport/serverUrl parsing from start.js
Move transport configuration parsing to cli.js as suggested in PR review. The CLI entry point (cli.js) should handle config file parsing and pass parameters to start.js.
---
client/bin/start.js | 24 ------------------------
1 file changed, 24 deletions(-)
diff --git a/client/bin/start.js b/client/bin/start.js
index 93a39ad3..ddf3fac3 100755
--- a/client/bin/start.js
+++ b/client/bin/start.js
@@ -18,8 +18,6 @@ function getClientUrl(
authDisabled,
sessionToken,
serverPort,
- transport,
- serverUrl,
) {
const host = process.env.HOST || "localhost";
const baseUrl = `http://${host}:${port}`;
@@ -31,12 +29,6 @@ function getClientUrl(
if (!authDisabled) {
params.set("MCP_PROXY_AUTH_TOKEN", sessionToken);
}
- if (transport) {
- params.set("transport", transport);
- }
- if (serverUrl) {
- params.set("serverUrl", serverUrl);
- }
return params.size > 0 ? `${baseUrl}/?${params.toString()}` : baseUrl;
}
@@ -136,8 +128,6 @@ async function startDevClient(clientOptions) {
sessionToken,
abort,
cancelled,
- transport,
- serverUrl,
} = clientOptions;
const clientCommand = "npx";
const host = process.env.HOST || "localhost";
@@ -155,8 +145,6 @@ async function startDevClient(clientOptions) {
authDisabled,
sessionToken,
SERVER_PORT,
- transport,
- serverUrl,
);
// Give vite time to start before opening or logging the URL
@@ -190,8 +178,6 @@ async function startProdClient(clientOptions) {
sessionToken,
abort,
cancelled,
- transport,
- serverUrl,
} = clientOptions;
const inspectorClientPath = resolve(
__dirname,
@@ -206,8 +192,6 @@ async function startProdClient(clientOptions) {
authDisabled,
sessionToken,
SERVER_PORT,
- transport,
- serverUrl,
);
await spawnPromise("node", [inspectorClientPath], {
@@ -229,8 +213,6 @@ async function main() {
let command = null;
let parsingFlags = true;
let isDev = false;
- let transport = null;
- let serverUrl = null;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
@@ -256,10 +238,6 @@ async function main() {
} else {
envVars[envVar] = "";
}
- } else if (parsingFlags && arg === "--transport" && i + 1 < args.length) {
- transport = args[++i];
- } else if (parsingFlags && arg === "--server-url" && i + 1 < args.length) {
- serverUrl = args[++i];
} else if (!command && !isDev) {
command = arg;
} else if (!isDev) {
@@ -319,8 +297,6 @@ async function main() {
sessionToken,
abort,
cancelled,
- transport,
- serverUrl,
};
await (isDev
From 63193ec00730e6d6d4502ceb43935cdfa277d15c Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Tue, 5 Aug 2025 13:31:40 +0100
Subject: [PATCH 12/25] Clean up CLI tests and add config parsing tests
- Remove web client launch tests from CLI test suite
- Add CLI-mode tests for SSE and streamable-http configs
- Keep only tests that use --cli flag as intended
---
cli/scripts/cli-tests.js | 18 ++++++++++--------
1 file changed, 10 insertions(+), 8 deletions(-)
diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js
index 857b5108..299c7e3d 100755
--- a/cli/scripts/cli-tests.js
+++ b/cli/scripts/cli-tests.js
@@ -744,26 +744,28 @@ async function runTests() {
"tools/list",
);
- // Test 26: Config with SSE transport type (should pass transport to client)
+ // Test 26: Config with SSE transport type (CLI mode)
await runBasicTest(
- "config_sse_type",
+ "config_sse_type_cli",
"--config",
sseConfigPath,
"--server",
"test-sse",
- "echo",
- "test",
+ "--cli",
+ "--method",
+ "tools/list",
);
- // Test 27: Config with streamable-http transport type
+ // Test 27: Config with streamable-http transport type (CLI mode)
await runBasicTest(
- "config_http_type",
+ "config_http_type_cli",
"--config",
httpConfigPath,
"--server",
"test-http",
- "echo",
- "test",
+ "--cli",
+ "--method",
+ "tools/list",
);
// Test 28: Legacy config without type field (backward compatibility)
From a7675d1be2690f9d602fa866e2614adca42b107d Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Tue, 5 Aug 2025 13:34:16 +0100
Subject: [PATCH 13/25] Add Playwright e2e tests for CLI argument handling
- Test that transport and serverUrl parameters are correctly passed via URL
- Verify SSE and streamable-http transport types are handled
- Test default STDIO behavior
---
client/e2e/cli-arguments.spec.ts | 61 ++++++++++++++++++++++++++++++++
1 file changed, 61 insertions(+)
create mode 100644 client/e2e/cli-arguments.spec.ts
diff --git a/client/e2e/cli-arguments.spec.ts b/client/e2e/cli-arguments.spec.ts
new file mode 100644
index 00000000..3376a6d7
--- /dev/null
+++ b/client/e2e/cli-arguments.spec.ts
@@ -0,0 +1,61 @@
+import { test, expect } from "@playwright/test";
+
+// These tests verify that CLI arguments correctly set URL parameters
+// The CLI should parse config files and pass transport/serverUrl as URL params
+test.describe("CLI Arguments @cli", () => {
+ test("should pass transport parameter from command line", async ({
+ page,
+ }) => {
+ // Simulate: npx . --transport sse --server-url http://localhost:3000/sse
+ await page.goto("http://localhost:6274/?transport=sse&serverUrl=http://localhost:3000/sse");
+
+ // Wait for the Transport Type dropdown to be visible
+ const selectTrigger = page.getByLabel("Transport Type");
+ await expect(selectTrigger).toBeVisible();
+
+ // Verify transport dropdown shows SSE
+ await expect(selectTrigger).toContainText("SSE");
+
+ // Verify URL field is visible and populated
+ const urlInput = page.locator("#sse-url-input");
+ await expect(urlInput).toBeVisible();
+ await expect(urlInput).toHaveValue("http://localhost:3000/sse");
+ });
+
+ test("should pass transport parameter for streamable-http", async ({
+ page,
+ }) => {
+ // Simulate config with streamable-http transport
+ await page.goto("http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:3000/mcp");
+
+ // Wait for the Transport Type dropdown to be visible
+ const selectTrigger = page.getByLabel("Transport Type");
+ await expect(selectTrigger).toBeVisible();
+
+ // Verify transport dropdown shows Streamable HTTP
+ await expect(selectTrigger).toContainText("Streamable HTTP");
+
+ // Verify URL field is visible and populated
+ const urlInput = page.locator("#sse-url-input");
+ await expect(urlInput).toBeVisible();
+ await expect(urlInput).toHaveValue("http://localhost:3000/mcp");
+ });
+
+ test("should not pass transport parameter for stdio config", async ({
+ page,
+ }) => {
+ // Simulate stdio config (no transport param needed)
+ await page.goto("http://localhost:6274/");
+
+ // Wait for the Transport Type dropdown to be visible
+ const selectTrigger = page.getByLabel("Transport Type");
+ await expect(selectTrigger).toBeVisible();
+
+ // Verify transport dropdown defaults to STDIO
+ await expect(selectTrigger).toContainText("STDIO");
+
+ // Verify command/args fields are visible
+ await expect(page.locator("#command-input")).toBeVisible();
+ await expect(page.locator("#arguments-input")).toBeVisible();
+ });
+});
\ No newline at end of file
From 0e2906c4c4e7088fe5fdc72ef25a877d9034e93b Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Tue, 5 Aug 2025 13:35:28 +0100
Subject: [PATCH 14/25] Implement default-server support
- Auto-select server when --config is used without --server
- Use 'default-server' if it exists in config
- Use single server if only one is defined
- Show helpful error if multiple servers exist without default
- Add tests for all default-server scenarios
---
cli/scripts/cli-tests.js | 108 ++++++++++++++++++++++++++++++++++++---
cli/src/cli.ts | 36 ++++++++++---
2 files changed, 132 insertions(+), 12 deletions(-)
diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js
index 299c7e3d..87981dd8 100755
--- a/cli/scripts/cli-tests.js
+++ b/cli/scripts/cli-tests.js
@@ -780,6 +780,102 @@ async function runTests() {
"tools/list",
);
+ console.log(
+ `\n${colors.YELLOW}=== Running Default Server Tests ===${colors.NC}`,
+ );
+
+ // Create config with single server for auto-selection
+ const singleServerConfigPath = path.join(TEMP_DIR, "single-server-config.json");
+ fs.writeFileSync(
+ singleServerConfigPath,
+ JSON.stringify(
+ {
+ mcpServers: {
+ "only-server": {
+ command: "npx",
+ args: ["@modelcontextprotocol/server-everything"],
+ },
+ },
+ },
+ null,
+ 2,
+ ),
+ );
+
+ // Create config with default-server
+ const defaultServerConfigPath = path.join(TEMP_DIR, "default-server-config.json");
+ fs.writeFileSync(
+ defaultServerConfigPath,
+ JSON.stringify(
+ {
+ mcpServers: {
+ "default-server": {
+ command: "npx",
+ args: ["@modelcontextprotocol/server-everything"],
+ },
+ "other-server": {
+ command: "node",
+ args: ["other.js"],
+ },
+ },
+ },
+ null,
+ 2,
+ ),
+ );
+
+ // Create config with multiple servers (no default)
+ const multiServerConfigPath = path.join(TEMP_DIR, "multi-server-config.json");
+ fs.writeFileSync(
+ multiServerConfigPath,
+ JSON.stringify(
+ {
+ mcpServers: {
+ "server1": {
+ command: "npx",
+ args: ["@modelcontextprotocol/server-everything"],
+ },
+ "server2": {
+ command: "node",
+ args: ["other.js"],
+ },
+ },
+ },
+ null,
+ 2,
+ ),
+ );
+
+ // Test 29: Config with single server auto-selection
+ await runBasicTest(
+ "single_server_auto_select",
+ "--config",
+ singleServerConfigPath,
+ "--cli",
+ "--method",
+ "tools/list",
+ );
+
+ // Test 30: Config with default-server auto-selection
+ await runBasicTest(
+ "default_server_auto_select",
+ "--config",
+ defaultServerConfigPath,
+ "--cli",
+ "--method",
+ "tools/list",
+ );
+
+ // Test 31: Config with multiple servers and no default (should fail)
+ await runErrorTest(
+ "multi_server_no_default",
+ "--config",
+ multiServerConfigPath,
+ "--cli",
+ "--method",
+ "tools/list",
+ );
+
console.log(
`\n${colors.YELLOW}=== Running HTTP Transport Tests ===${colors.NC}`,
);
@@ -799,7 +895,7 @@ async function runTests() {
await new Promise((resolve) => setTimeout(resolve, 3000));
- // Test 29: HTTP transport inferred from URL ending with /mcp
+ // Test 32: HTTP transport inferred from URL ending with /mcp
await runBasicTest(
"http_transport_inferred",
"http://127.0.0.1:3001/mcp",
@@ -808,7 +904,7 @@ async function runTests() {
"tools/list",
);
- // Test 30: HTTP transport with explicit --transport http flag
+ // Test 33: HTTP transport with explicit --transport http flag
await runBasicTest(
"http_transport_with_explicit_flag",
"http://127.0.0.1:3001/mcp",
@@ -819,7 +915,7 @@ async function runTests() {
"tools/list",
);
- // Test 31: HTTP transport with suffix and --transport http flag
+ // Test 34: HTTP transport with suffix and --transport http flag
await runBasicTest(
"http_transport_with_explicit_flag_and_suffix",
"http://127.0.0.1:3001/mcp",
@@ -830,7 +926,7 @@ async function runTests() {
"tools/list",
);
- // Test 32: SSE transport given to HTTP server (should fail)
+ // Test 35: SSE transport given to HTTP server (should fail)
await runErrorTest(
"sse_transport_given_to_http_server",
"http://127.0.0.1:3001",
@@ -841,7 +937,7 @@ async function runTests() {
"tools/list",
);
- // Test 33: HTTP transport without URL (should fail)
+ // Test 36: HTTP transport without URL (should fail)
await runErrorTest(
"http_transport_without_url",
"--transport",
@@ -851,7 +947,7 @@ async function runTests() {
"tools/list",
);
- // Test 34: SSE transport without URL (should fail)
+ // Test 37: SSE transport without URL (should fail)
await runErrorTest(
"sse_transport_without_url",
"--transport",
diff --git a/cli/src/cli.ts b/cli/src/cli.ts
index d620958b..cf8662e5 100644
--- a/cli/src/cli.ts
+++ b/cli/src/cli.ts
@@ -220,16 +220,40 @@ function parseArgs(): Args {
// Add back any arguments that came after --
const finalArgs = [...remainingArgs, ...postArgs];
- // Validate that config and server are provided together
- if (
- (options.config && !options.server) ||
- (!options.config && options.server)
- ) {
+ // Validate config and server options
+ if (!options.config && options.server) {
throw new Error(
- "Both --config and --server must be provided together. If you specify one, you must specify the other.",
+ "--server requires --config to be specified",
);
}
+ // If config is provided without server, try to auto-select
+ if (options.config && !options.server) {
+ const configContent = fs.readFileSync(
+ path.isAbsolute(options.config)
+ ? options.config
+ : path.resolve(process.cwd(), options.config),
+ "utf8"
+ );
+ const parsedConfig = JSON.parse(configContent);
+ const servers = Object.keys(parsedConfig.mcpServers || {});
+
+ if (servers.includes("default-server")) {
+ // Use default-server if it exists
+ options.server = "default-server";
+ } else if (servers.length === 1) {
+ // Use the only server if there's just one
+ options.server = servers[0];
+ } else if (servers.length === 0) {
+ throw new Error("No servers found in config file");
+ } else {
+ // Multiple servers, no default-server
+ throw new Error(
+ `Multiple servers found in config file. Please specify one with --server.\nAvailable servers: ${servers.join(", ")}`
+ );
+ }
+ }
+
// If config file is specified, load and use the options from the file. We must merge the args
// from the command line and the file together, or we will miss the method options (--method,
// etc.)
From 3a4f3bc5108e4922c444e3765cda6a2bd24e9d78 Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Tue, 5 Aug 2025 13:36:54 +0100
Subject: [PATCH 15/25] Document transport types and default-server
functionality
- Add examples for stdio, sse, and streamable-http transport types
- Document automatic server selection behavior
- Show how to use default-server in config files
---
README.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 67 insertions(+)
diff --git a/README.md b/README.md
index 6a671f1c..932c337d 100644
--- a/README.md
+++ b/README.md
@@ -234,6 +234,73 @@ Example server configuration file:
}
```
+#### Transport Types in Config Files
+
+The inspector automatically detects the transport type from your config file. You can specify different transport types:
+
+**STDIO (default):**
+```json
+{
+ "mcpServers": {
+ "my-stdio-server": {
+ "type": "stdio",
+ "command": "npx",
+ "args": ["@modelcontextprotocol/server-everything"]
+ }
+ }
+}
+```
+
+**SSE (Server-Sent Events):**
+```json
+{
+ "mcpServers": {
+ "my-sse-server": {
+ "type": "sse",
+ "url": "http://localhost:3000/sse"
+ }
+ }
+}
+```
+
+**Streamable HTTP:**
+```json
+{
+ "mcpServers": {
+ "my-http-server": {
+ "type": "streamable-http",
+ "url": "http://localhost:3000/mcp"
+ }
+ }
+}
+```
+
+#### Default Server Selection
+
+You can launch the inspector without specifying a server name if your config has:
+
+1. **A single server** - automatically selected:
+```bash
+# Automatically uses "my-server" if it's the only one
+npx @modelcontextprotocol/inspector --config config.json
+```
+
+2. **A server named "default-server"** - automatically selected:
+```json
+{
+ "mcpServers": {
+ "default-server": {
+ "command": "npx",
+ "args": ["@modelcontextprotocol/server-everything"]
+ },
+ "other-server": {
+ "command": "node",
+ "args": ["other.js"]
+ }
+ }
+}
+```
+
> **Tip:** You can easily generate this configuration format using the **Server Entry** and **Servers File** buttons in the Inspector UI, as described in the Servers File Export section above.
You can also set the initial `transport` type, `serverUrl`, `serverCommand`, and `serverArgs` via query params, for example:
From 1e399b70554bbadba70ae224fb3d3c55c6b6b89d Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Tue, 5 Aug 2025 14:08:26 +0100
Subject: [PATCH 16/25] prettier
---
README.md | 5 +++++
cli/scripts/cli-tests.js | 14 ++++++++++----
cli/src/cli.ts | 10 ++++------
client/bin/start.js | 7 +------
client/e2e/cli-arguments.spec.ts | 10 +++++++---
5 files changed, 27 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index 932c337d..69eada69 100644
--- a/README.md
+++ b/README.md
@@ -239,6 +239,7 @@ Example server configuration file:
The inspector automatically detects the transport type from your config file. You can specify different transport types:
**STDIO (default):**
+
```json
{
"mcpServers": {
@@ -252,6 +253,7 @@ The inspector automatically detects the transport type from your config file. Yo
```
**SSE (Server-Sent Events):**
+
```json
{
"mcpServers": {
@@ -264,6 +266,7 @@ The inspector automatically detects the transport type from your config file. Yo
```
**Streamable HTTP:**
+
```json
{
"mcpServers": {
@@ -280,12 +283,14 @@ The inspector automatically detects the transport type from your config file. Yo
You can launch the inspector without specifying a server name if your config has:
1. **A single server** - automatically selected:
+
```bash
# Automatically uses "my-server" if it's the only one
npx @modelcontextprotocol/inspector --config config.json
```
2. **A server named "default-server"** - automatically selected:
+
```json
{
"mcpServers": {
diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js
index 87981dd8..e737756f 100755
--- a/cli/scripts/cli-tests.js
+++ b/cli/scripts/cli-tests.js
@@ -785,7 +785,10 @@ async function runTests() {
);
// Create config with single server for auto-selection
- const singleServerConfigPath = path.join(TEMP_DIR, "single-server-config.json");
+ const singleServerConfigPath = path.join(
+ TEMP_DIR,
+ "single-server-config.json",
+ );
fs.writeFileSync(
singleServerConfigPath,
JSON.stringify(
@@ -803,7 +806,10 @@ async function runTests() {
);
// Create config with default-server
- const defaultServerConfigPath = path.join(TEMP_DIR, "default-server-config.json");
+ const defaultServerConfigPath = path.join(
+ TEMP_DIR,
+ "default-server-config.json",
+ );
fs.writeFileSync(
defaultServerConfigPath,
JSON.stringify(
@@ -831,11 +837,11 @@ async function runTests() {
JSON.stringify(
{
mcpServers: {
- "server1": {
+ server1: {
command: "npx",
args: ["@modelcontextprotocol/server-everything"],
},
- "server2": {
+ server2: {
command: "node",
args: ["other.js"],
},
diff --git a/cli/src/cli.ts b/cli/src/cli.ts
index cf8662e5..09c99d51 100644
--- a/cli/src/cli.ts
+++ b/cli/src/cli.ts
@@ -222,9 +222,7 @@ function parseArgs(): Args {
// Validate config and server options
if (!options.config && options.server) {
- throw new Error(
- "--server requires --config to be specified",
- );
+ throw new Error("--server requires --config to be specified");
}
// If config is provided without server, try to auto-select
@@ -233,11 +231,11 @@ function parseArgs(): Args {
path.isAbsolute(options.config)
? options.config
: path.resolve(process.cwd(), options.config),
- "utf8"
+ "utf8",
);
const parsedConfig = JSON.parse(configContent);
const servers = Object.keys(parsedConfig.mcpServers || {});
-
+
if (servers.includes("default-server")) {
// Use default-server if it exists
options.server = "default-server";
@@ -249,7 +247,7 @@ function parseArgs(): Args {
} else {
// Multiple servers, no default-server
throw new Error(
- `Multiple servers found in config file. Please specify one with --server.\nAvailable servers: ${servers.join(", ")}`
+ `Multiple servers found in config file. Please specify one with --server.\nAvailable servers: ${servers.join(", ")}`,
);
}
}
diff --git a/client/bin/start.js b/client/bin/start.js
index ddf3fac3..e0496cde 100755
--- a/client/bin/start.js
+++ b/client/bin/start.js
@@ -13,12 +13,7 @@ function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms, true));
}
-function getClientUrl(
- port,
- authDisabled,
- sessionToken,
- serverPort,
-) {
+function getClientUrl(port, authDisabled, sessionToken, serverPort) {
const host = process.env.HOST || "localhost";
const baseUrl = `http://${host}:${port}`;
diff --git a/client/e2e/cli-arguments.spec.ts b/client/e2e/cli-arguments.spec.ts
index 3376a6d7..a4dcdcce 100644
--- a/client/e2e/cli-arguments.spec.ts
+++ b/client/e2e/cli-arguments.spec.ts
@@ -7,7 +7,9 @@ test.describe("CLI Arguments @cli", () => {
page,
}) => {
// Simulate: npx . --transport sse --server-url http://localhost:3000/sse
- await page.goto("http://localhost:6274/?transport=sse&serverUrl=http://localhost:3000/sse");
+ await page.goto(
+ "http://localhost:6274/?transport=sse&serverUrl=http://localhost:3000/sse",
+ );
// Wait for the Transport Type dropdown to be visible
const selectTrigger = page.getByLabel("Transport Type");
@@ -26,7 +28,9 @@ test.describe("CLI Arguments @cli", () => {
page,
}) => {
// Simulate config with streamable-http transport
- await page.goto("http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:3000/mcp");
+ await page.goto(
+ "http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:3000/mcp",
+ );
// Wait for the Transport Type dropdown to be visible
const selectTrigger = page.getByLabel("Transport Type");
@@ -58,4 +62,4 @@ test.describe("CLI Arguments @cli", () => {
await expect(page.locator("#command-input")).toBeVisible();
await expect(page.locator("#arguments-input")).toBeVisible();
});
-});
\ No newline at end of file
+});
From 195f79076ea3414965454112988a087130905993 Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Tue, 5 Aug 2025 14:21:18 +0100
Subject: [PATCH 17/25] Fix CLI test failures for SSE/HTTP transport configs
- Pass URL as command for SSE/HTTP configs instead of empty string
- Update runCli to pass transport type flag when needed
- Update tests to expect connection errors for non-existent SSE/HTTP servers
---
cli/scripts/cli-tests.js | 8 ++++----
cli/src/cli.ts | 15 +++++++++++++--
2 files changed, 17 insertions(+), 6 deletions(-)
diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js
index e737756f..675ea0bc 100755
--- a/cli/scripts/cli-tests.js
+++ b/cli/scripts/cli-tests.js
@@ -744,8 +744,8 @@ async function runTests() {
"tools/list",
);
- // Test 26: Config with SSE transport type (CLI mode)
- await runBasicTest(
+ // Test 26: Config with SSE transport type (CLI mode) - expects connection error
+ await runErrorTest(
"config_sse_type_cli",
"--config",
sseConfigPath,
@@ -756,8 +756,8 @@ async function runTests() {
"tools/list",
);
- // Test 27: Config with streamable-http transport type (CLI mode)
- await runBasicTest(
+ // Test 27: Config with streamable-http transport type (CLI mode) - expects connection error
+ await runErrorTest(
"config_http_type_cli",
"--config",
httpConfigPath,
diff --git a/cli/src/cli.ts b/cli/src/cli.ts
index 09c99d51..df16f0cc 100644
--- a/cli/src/cli.ts
+++ b/cli/src/cli.ts
@@ -122,7 +122,18 @@ async function runCli(args: Args): Promise {
});
try {
- await spawnPromise("node", [cliPath, args.command, ...args.args], {
+ // Build CLI arguments
+ const cliArgs = [cliPath];
+
+ // Add transport flag if specified
+ if (args.transport && args.transport !== "stdio") {
+ cliArgs.push("--transport", args.transport);
+ }
+
+ // Add command and remaining args
+ cliArgs.push(args.command, ...args.args);
+
+ await spawnPromise("node", cliArgs, {
env: { ...process.env, ...args.envArgs },
signal: abort.signal,
echoOutput: true,
@@ -268,7 +279,7 @@ function parseArgs(): Args {
};
} else if (config.type === "sse" || config.type === "streamable-http") {
return {
- command: "",
+ command: config.url,
args: finalArgs,
envArgs: options.e || {},
cli: options.cli || false,
From 7ea47c0caa123f7eb96df361d575857c263aded7 Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Tue, 5 Aug 2025 19:38:44 +0100
Subject: [PATCH 18/25] Fix transport type propagation from CLI to web client
- Add --transport and --server-url support to client/bin/start.js
- Pass transport configuration through server to client via /config endpoint
- Update client App.tsx to use defaultTransport and defaultServerUrl from server
- Ensure SSE and HTTP transport configs work properly from CLI
---
client/bin/start.js | 31 +++++++++++++++++++++++++++++--
client/src/App.tsx | 8 ++++++++
server/src/index.ts | 4 ++++
3 files changed, 41 insertions(+), 2 deletions(-)
diff --git a/client/bin/start.js b/client/bin/start.js
index e0496cde..f67301de 100755
--- a/client/bin/start.js
+++ b/client/bin/start.js
@@ -28,8 +28,15 @@ function getClientUrl(port, authDisabled, sessionToken, serverPort) {
}
async function startDevServer(serverOptions) {
- const { SERVER_PORT, CLIENT_PORT, sessionToken, envVars, abort } =
- serverOptions;
+ const {
+ SERVER_PORT,
+ CLIENT_PORT,
+ sessionToken,
+ envVars,
+ abort,
+ transport,
+ serverUrl,
+ } = serverOptions;
const serverCommand = "npx";
const serverArgs = ["tsx", "watch", "--clear-screen=false", "src/index.ts"];
const isWindows = process.platform === "win32";
@@ -42,6 +49,8 @@ async function startDevServer(serverOptions) {
CLIENT_PORT,
MCP_PROXY_AUTH_TOKEN: sessionToken,
MCP_ENV_VARS: JSON.stringify(envVars),
+ ...(transport ? { MCP_TRANSPORT: transport } : {}),
+ ...(serverUrl ? { MCP_SERVER_URL: serverUrl } : {}),
},
signal: abort.signal,
echoOutput: true,
@@ -78,6 +87,8 @@ async function startProdServer(serverOptions) {
abort,
command,
mcpServerArgs,
+ transport,
+ serverUrl,
} = serverOptions;
const inspectorServerPath = resolve(
__dirname,
@@ -95,6 +106,8 @@ async function startProdServer(serverOptions) {
...(mcpServerArgs && mcpServerArgs.length > 0
? [`--args=${mcpServerArgs.join(" ")}`]
: []),
+ ...(transport ? [`--transport=${transport}`] : []),
+ ...(serverUrl ? [`--server-url=${serverUrl}`] : []),
],
{
env: {
@@ -208,6 +221,8 @@ async function main() {
let command = null;
let parsingFlags = true;
let isDev = false;
+ let transport = null;
+ let serverUrl = null;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
@@ -222,6 +237,16 @@ async function main() {
continue;
}
+ if (parsingFlags && arg === "--transport" && i + 1 < args.length) {
+ transport = args[++i];
+ continue;
+ }
+
+ if (parsingFlags && arg === "--server-url" && i + 1 < args.length) {
+ serverUrl = args[++i];
+ continue;
+ }
+
if (parsingFlags && arg === "-e" && i + 1 < args.length) {
const envVar = args[++i];
const equalsIndex = envVar.indexOf("=");
@@ -273,6 +298,8 @@ async function main() {
abort,
command,
mcpServerArgs,
+ transport,
+ serverUrl,
};
const result = isDev
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 537f80bf..d6680c35 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -455,6 +455,14 @@ const App = () => {
if (data.defaultArgs) {
setArgs(data.defaultArgs);
}
+ if (data.defaultTransport) {
+ setTransportType(
+ data.defaultTransport as "stdio" | "sse" | "streamable-http",
+ );
+ }
+ if (data.defaultServerUrl) {
+ setSseUrl(data.defaultServerUrl);
+ }
})
.catch((error) =>
console.error("Error fetching default environment:", error),
diff --git a/server/src/index.ts b/server/src/index.ts
index 92badc2c..34a69414 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -40,6 +40,8 @@ const { values } = parseArgs({
env: { type: "string", default: "" },
args: { type: "string", default: "" },
command: { type: "string", default: "" },
+ transport: { type: "string", default: "" },
+ "server-url": { type: "string", default: "" },
},
});
@@ -523,6 +525,8 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => {
defaultEnvironment,
defaultCommand: values.command,
defaultArgs: values.args,
+ defaultTransport: values.transport,
+ defaultServerUrl: values["server-url"],
});
} catch (error) {
console.error("Error in /config route:", error);
From 98b7e63bb032b6aae4767712af4359594271a29a Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Wed, 6 Aug 2025 19:50:23 +0100
Subject: [PATCH 19/25] Add --transport flag support to CLI
- Add --transport option to command line parser
- Support --transport stdio/sse/http flags
- Convert 'http' to 'streamable-http' internally
- Fix transport type conversion for CLI mode
---
cli/src/cli.ts | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/cli/src/cli.ts b/cli/src/cli.ts
index df16f0cc..6c72e6a4 100644
--- a/cli/src/cli.ts
+++ b/cli/src/cli.ts
@@ -23,6 +23,7 @@ type CliOptions = {
config?: string;
server?: string;
cli?: boolean;
+ transport?: string;
};
type ServerConfig =
@@ -127,7 +128,9 @@ async function runCli(args: Args): Promise {
// Add transport flag if specified
if (args.transport && args.transport !== "stdio") {
- cliArgs.push("--transport", args.transport);
+ // Convert streamable-http back to http for CLI mode
+ const cliTransport = args.transport === "streamable-http" ? "http" : args.transport;
+ cliArgs.push("--transport", cliTransport);
}
// Add command and remaining args
@@ -220,7 +223,8 @@ function parseArgs(): Args {
)
.option("--config ", "config file path")
.option("--server ", "server name from config file")
- .option("--cli", "enable CLI mode");
+ .option("--cli", "enable CLI mode")
+ .option("--transport ", "transport type (stdio, sse, http)");
// Parse only the arguments before --
program.parse(preArgs);
@@ -301,12 +305,19 @@ function parseArgs(): Args {
// Otherwise use command line arguments
const command = finalArgs[0] || "";
const args = finalArgs.slice(1);
+
+ // Map "http" shorthand to "streamable-http"
+ let transport = options.transport;
+ if (transport === "http") {
+ transport = "streamable-http";
+ }
return {
command,
args,
envArgs: options.e || {},
cli: options.cli || false,
+ transport: transport as "stdio" | "sse" | "streamable-http" | undefined,
};
}
From e1e702ff14791e653535901f05b6545c504cae84 Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Thu, 7 Aug 2025 11:42:31 +0100
Subject: [PATCH 20/25] ignore: ignore mcp.json as a common dev file
---
.gitignore | 1 +
.prettierignore | 1 +
README.md | 2 +-
3 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index eeee17fe..6ce55639 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,4 @@ sdk
client/playwright-report/
client/results.json
client/test-results/
+mcp.json
diff --git a/.prettierignore b/.prettierignore
index c8824c9a..cd910373 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,3 +2,4 @@ packages
server/build
CODE_OF_CONDUCT.md
SECURITY.md
+mcp.json
diff --git a/README.md b/README.md
index 69eada69..ed1e5093 100644
--- a/README.md
+++ b/README.md
@@ -286,7 +286,7 @@ You can launch the inspector without specifying a server name if your config has
```bash
# Automatically uses "my-server" if it's the only one
-npx @modelcontextprotocol/inspector --config config.json
+npx @modelcontextprotocol/inspector --config mcp.json
```
2. **A server named "default-server"** - automatically selected:
From cf7c138958a32b3b613d87a743b8271b8d082386 Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Thu, 7 Aug 2025 11:51:36 +0100
Subject: [PATCH 21/25] Fix --server-url flag not being parsed by CLI
Add --server-url option to CLI parser so the flag is properly recognized
and passed through to the server process via the /config endpoint.
---
cli/src/cli.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/cli/src/cli.ts b/cli/src/cli.ts
index 6c72e6a4..4bc7b6b1 100644
--- a/cli/src/cli.ts
+++ b/cli/src/cli.ts
@@ -24,6 +24,7 @@ type CliOptions = {
server?: string;
cli?: boolean;
transport?: string;
+ serverUrl?: string;
};
type ServerConfig =
@@ -224,7 +225,8 @@ function parseArgs(): Args {
.option("--config ", "config file path")
.option("--server ", "server name from config file")
.option("--cli", "enable CLI mode")
- .option("--transport ", "transport type (stdio, sse, http)");
+ .option("--transport ", "transport type (stdio, sse, http)")
+ .option("--server-url ", "server URL for SSE/HTTP transport");
// Parse only the arguments before --
program.parse(preArgs);
@@ -318,6 +320,7 @@ function parseArgs(): Args {
envArgs: options.e || {},
cli: options.cli || false,
transport: transport as "stdio" | "sse" | "streamable-http" | undefined,
+ serverUrl: options.serverUrl,
};
}
From 14b01815adb5f3f7333a086d58affee6e6b3c0b0 Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Thu, 7 Aug 2025 12:01:17 +0100
Subject: [PATCH 22/25] prettier
---
cli/src/cli.ts | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/cli/src/cli.ts b/cli/src/cli.ts
index 4bc7b6b1..177ac058 100644
--- a/cli/src/cli.ts
+++ b/cli/src/cli.ts
@@ -130,7 +130,8 @@ async function runCli(args: Args): Promise {
// Add transport flag if specified
if (args.transport && args.transport !== "stdio") {
// Convert streamable-http back to http for CLI mode
- const cliTransport = args.transport === "streamable-http" ? "http" : args.transport;
+ const cliTransport =
+ args.transport === "streamable-http" ? "http" : args.transport;
cliArgs.push("--transport", cliTransport);
}
@@ -307,7 +308,7 @@ function parseArgs(): Args {
// Otherwise use command line arguments
const command = finalArgs[0] || "";
const args = finalArgs.slice(1);
-
+
// Map "http" shorthand to "streamable-http"
let transport = options.transport;
if (transport === "http") {
From 1263ffdf58433d547423e18d861c8de764bf719d Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Thu, 7 Aug 2025 20:45:23 +0100
Subject: [PATCH 23/25] Simplify server auto-selection logic
Only auto-select when there's exactly one server in the config file.
Removes special handling for "default-server" naming convention.
Addresses review feedback from @ochafik on PR #661
---
cli/src/cli.ts | 7 ++-----
1 file changed, 2 insertions(+), 5 deletions(-)
diff --git a/cli/src/cli.ts b/cli/src/cli.ts
index 177ac058..13c7e492 100644
--- a/cli/src/cli.ts
+++ b/cli/src/cli.ts
@@ -254,16 +254,13 @@ function parseArgs(): Args {
const parsedConfig = JSON.parse(configContent);
const servers = Object.keys(parsedConfig.mcpServers || {});
- if (servers.includes("default-server")) {
- // Use default-server if it exists
- options.server = "default-server";
- } else if (servers.length === 1) {
+ if (servers.length === 1) {
// Use the only server if there's just one
options.server = servers[0];
} else if (servers.length === 0) {
throw new Error("No servers found in config file");
} else {
- // Multiple servers, no default-server
+ // Multiple servers, require explicit selection
throw new Error(
`Multiple servers found in config file. Please specify one with --server.\nAvailable servers: ${servers.join(", ")}`,
);
From 68319704f07a39c1a4395ad131e4bd9487df4241 Mon Sep 17 00:00:00 2001
From: Felix Weinberger
Date: Thu, 7 Aug 2025 21:02:29 +0100
Subject: [PATCH 24/25] Update test to match new server selection behavior
Test 30 now expects an error when multiple servers exist, even with
"default-server" present, aligning with the simplified logic that only
auto-selects when there's exactly one server.
---
cli/scripts/cli-tests.js | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js
index 675ea0bc..f714cb6a 100755
--- a/cli/scripts/cli-tests.js
+++ b/cli/scripts/cli-tests.js
@@ -862,9 +862,9 @@ async function runTests() {
"tools/list",
);
- // Test 30: Config with default-server auto-selection
- await runBasicTest(
- "default_server_auto_select",
+ // Test 30: Config with default-server should now require explicit selection (multiple servers)
+ await runErrorTest(
+ "default_server_requires_explicit_selection",
"--config",
defaultServerConfigPath,
"--cli",
From b89b10beac5645114669e8f89ccf54fdecf4ed19 Mon Sep 17 00:00:00 2001
From: olaservo
Date: Fri, 8 Aug 2025 15:46:58 -0700
Subject: [PATCH 25/25] Bump version
---
cli/package.json | 2 +-
client/package.json | 2 +-
package-lock.json | 16 ++++++++--------
package.json | 8 ++++----
server/package.json | 2 +-
5 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/cli/package.json b/cli/package.json
index bf344733..6bb55253 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-cli",
- "version": "0.16.2",
+ "version": "0.16.3",
"description": "CLI for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
diff --git a/client/package.json b/client/package.json
index cbb7d8b6..d1c6d775 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
- "version": "0.16.2",
+ "version": "0.16.3",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
diff --git a/package-lock.json b/package-lock.json
index 89c27a17..f088d270 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@modelcontextprotocol/inspector",
- "version": "0.16.2",
+ "version": "0.16.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@modelcontextprotocol/inspector",
- "version": "0.16.2",
+ "version": "0.16.3",
"license": "MIT",
"workspaces": [
"client",
@@ -14,9 +14,9 @@
"cli"
],
"dependencies": {
- "@modelcontextprotocol/inspector-cli": "^0.16.2",
- "@modelcontextprotocol/inspector-client": "^0.16.2",
- "@modelcontextprotocol/inspector-server": "^0.16.2",
+ "@modelcontextprotocol/inspector-cli": "^0.16.3",
+ "@modelcontextprotocol/inspector-client": "^0.16.3",
+ "@modelcontextprotocol/inspector-server": "^0.16.3",
"@modelcontextprotocol/sdk": "^1.17.0",
"concurrently": "^9.2.0",
"open": "^10.2.0",
@@ -44,7 +44,7 @@
},
"cli": {
"name": "@modelcontextprotocol/inspector-cli",
- "version": "0.16.2",
+ "version": "0.16.3",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.0",
@@ -67,7 +67,7 @@
},
"client": {
"name": "@modelcontextprotocol/inspector-client",
- "version": "0.16.2",
+ "version": "0.16.3",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.0",
@@ -11059,7 +11059,7 @@
},
"server": {
"name": "@modelcontextprotocol/inspector-server",
- "version": "0.16.2",
+ "version": "0.16.3",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.0",
diff --git a/package.json b/package.json
index e614ba21..9de5f464 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector",
- "version": "0.16.2",
+ "version": "0.16.3",
"description": "Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -47,9 +47,9 @@
"check-version": "node scripts/check-version-consistency.js"
},
"dependencies": {
- "@modelcontextprotocol/inspector-cli": "^0.16.2",
- "@modelcontextprotocol/inspector-client": "^0.16.2",
- "@modelcontextprotocol/inspector-server": "^0.16.2",
+ "@modelcontextprotocol/inspector-cli": "^0.16.3",
+ "@modelcontextprotocol/inspector-client": "^0.16.3",
+ "@modelcontextprotocol/inspector-server": "^0.16.3",
"@modelcontextprotocol/sdk": "^1.17.0",
"concurrently": "^9.2.0",
"open": "^10.2.0",
diff --git a/server/package.json b/server/package.json
index 51fa6e42..04135574 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-server",
- "version": "0.16.2",
+ "version": "0.16.3",
"description": "Server-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",