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 6a671f1c..ed1e5093 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,78 @@ 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 mcp.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: 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/cli/scripts/cli-tests.js b/cli/scripts/cli-tests.js index 68ce3885..f714cb6a 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,160 @@ 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 (CLI mode) - expects connection error + await runErrorTest( + "config_sse_type_cli", + "--config", + sseConfigPath, + "--server", + "test-sse", + "--cli", + "--method", + "tools/list", + ); + + // Test 27: Config with streamable-http transport type (CLI mode) - expects connection error + await runErrorTest( + "config_http_type_cli", + "--config", + httpConfigPath, + "--server", + "test-http", + "--cli", + "--method", + "tools/list", + ); + + // 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 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 should now require explicit selection (multiple servers) + await runErrorTest( + "default_server_requires_explicit_selection", + "--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}`, ); @@ -668,7 +901,7 @@ async function runTests() { await new Promise((resolve) => setTimeout(resolve, 3000)); - // Test 25: 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", @@ -677,7 +910,7 @@ async function runTests() { "tools/list", ); - // Test 26: 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", @@ -688,7 +921,7 @@ async function runTests() { "tools/list", ); - // Test 27: 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", @@ -699,7 +932,7 @@ async function runTests() { "tools/list", ); - // Test 28: 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", @@ -710,7 +943,7 @@ async function runTests() { "tools/list", ); - // Test 29: HTTP transport without URL (should fail) + // Test 36: HTTP transport without URL (should fail) await runErrorTest( "http_transport_without_url", "--transport", @@ -720,7 +953,7 @@ async function runTests() { "tools/list", ); - // Test 30: 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 5ff1f111..13c7e492 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 = { @@ -21,13 +23,22 @@ type CliOptions = { config?: string; server?: string; cli?: boolean; + transport?: string; + serverUrl?: string; }; -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 +85,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); @@ -103,7 +124,21 @@ 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") { + // 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 + cliArgs.push(args.command, ...args.args); + + await spawnPromise("node", cliArgs, { env: { ...process.env, ...args.envArgs }, signal: abort.signal, echoOutput: true, @@ -190,7 +225,9 @@ 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)") + .option("--server-url ", "server URL for SSE/HTTP transport"); // Parse only the arguments before -- program.parse(preArgs); @@ -201,14 +238,33 @@ 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) - ) { - throw new Error( - "Both --config and --server must be provided together. If you specify one, you must specify the other.", + // Validate config and server options + if (!options.config && options.server) { + throw new Error("--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.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, require explicit selection + 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 @@ -217,23 +273,52 @@ 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: config.url, + 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 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, + serverUrl: options.serverUrl, }; } 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/e2e/cli-arguments.spec.ts b/client/e2e/cli-arguments.spec.ts new file mode 100644 index 00000000..a4dcdcce --- /dev/null +++ b/client/e2e/cli-arguments.spec.ts @@ -0,0 +1,65 @@ +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(); + }); +}); 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/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/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 849a2aa2..2214add9 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") { @@ -181,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 && @@ -337,6 +353,8 @@ const DynamicJsonForm = ({ required={isRequired} /> ); + case "null": + return null; case "object": if (!propSchema.properties) { return ( 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/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", () => { 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[] = [ { 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..7a175142 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -145,6 +145,95 @@ 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 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 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 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 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; +} + /** * Formats a field key into a human-readable label * @param key The field key to format 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)", 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);