diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 28e0bbd5..0db5467a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,13 +1,13 @@ --- name: Bug report about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - +title: "" +labels: "" +assignees: "" --- **Inspector Version** + - [e.g. 0.16.5) **Describe the bug** @@ -15,6 +15,7 @@ A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' @@ -27,8 +28,9 @@ A clear and concise description of what you expected to happen. If applicable, add screenshots to help explain your problem. **Environment (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] + +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] **Additional context** Add any other context about the problem here. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..ec826b1f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,33 @@ +# MCP Inspector Development Guide + +## Build Commands + +- Build all: `npm run build` +- Build client: `npm run build-client` +- Build server: `npm run build-server` +- Development mode: `npm run dev` (use `npm run dev:windows` on Windows) +- Format code: `npm run prettier-fix` +- Client lint: `cd client && npm run lint` + +## Code Style Guidelines + +- Use TypeScript with proper type annotations +- Follow React functional component patterns with hooks +- Use ES modules (import/export) not CommonJS +- Use Prettier for formatting (auto-formatted on commit) +- Follow existing naming conventions: + - camelCase for variables and functions + - PascalCase for component names and types + - kebab-case for file names +- Use async/await for asynchronous operations +- Implement proper error handling with try/catch blocks +- Use Tailwind CSS for styling in the client +- Keep components small and focused on a single responsibility + +## Project Organization + +The project is organized as a monorepo with workspaces: + +- `client/`: React frontend with Vite, TypeScript and Tailwind +- `server/`: Express backend with TypeScript +- `cli/`: Command-line interface for testing and invoking MCP server methods directly diff --git a/CLAUDE.md b/CLAUDE.md index ec826b1f..285e0f5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,33 +1 @@ -# MCP Inspector Development Guide - -## Build Commands - -- Build all: `npm run build` -- Build client: `npm run build-client` -- Build server: `npm run build-server` -- Development mode: `npm run dev` (use `npm run dev:windows` on Windows) -- Format code: `npm run prettier-fix` -- Client lint: `cd client && npm run lint` - -## Code Style Guidelines - -- Use TypeScript with proper type annotations -- Follow React functional component patterns with hooks -- Use ES modules (import/export) not CommonJS -- Use Prettier for formatting (auto-formatted on commit) -- Follow existing naming conventions: - - camelCase for variables and functions - - PascalCase for component names and types - - kebab-case for file names -- Use async/await for asynchronous operations -- Implement proper error handling with try/catch blocks -- Use Tailwind CSS for styling in the client -- Keep components small and focused on a single responsibility - -## Project Organization - -The project is organized as a monorepo with workspaces: - -- `client/`: React frontend with Vite, TypeScript and Tailwind -- `server/`: Express backend with TypeScript -- `cli/`: Command-line interface for testing and invoking MCP server methods directly +@./AGENTS.md diff --git a/README.md b/README.md index fc563f91..29037550 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,16 @@ The MCP Inspector provides convenient buttons to export server launch configurat } ``` + **Streamable HTTP transport example:** + + ```json + { + "type": "streamable-http", + "url": "http://localhost:3000/mcp", + "note": "For Streamable HTTP connections, add this URL directly in your MCP Client" + } + ``` + - **Servers File** - Copies a complete MCP configuration file structure to your clipboard, with your current server configuration added as `default-server`. This can be saved directly as `mcp.json`. **STDIO transport example:** @@ -131,9 +141,23 @@ The MCP Inspector provides convenient buttons to export server launch configurat } ``` + **Streamable HTTP transport example:** + + ```json + { + "mcpServers": { + "default-server": { + "type": "streamable-http", + "url": "http://localhost:3000/mcp", + "note": "For Streamable HTTP connections, add this URL directly in your MCP Client" + } + } + } + ``` + These buttons appear in the Inspector UI after you've configured your server settings, making it easy to save and reuse your configurations. -For SSE transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the SSE URL configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the SSE URL for direct use in clients. +For SSE and Streamable HTTP transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the URL for direct use in clients. You can paste the Server Entry into your existing `mcp.json` file under your chosen server name, or use the complete Servers File payload to create a new configuration file. diff --git a/cli/package.json b/cli/package.json index 506b1856..ddeb3b93 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "0.16.6", + "version": "0.17.0", "description": "CLI for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -24,7 +24,7 @@ }, "devDependencies": {}, "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.5", + "@modelcontextprotocol/sdk": "^1.18.0", "commander": "^13.1.0", "spawn-rx": "^5.1.2" } diff --git a/cli/src/index.ts b/cli/src/index.ts index 8e219e45..90c9f5e6 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import * as fs from "fs"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { Command } from "commander"; import { @@ -99,16 +100,27 @@ function createTransportOptions( } async function callMethod(args: Args): Promise { + // Read package.json to get name and version for client identity + const pathA = "../package.json"; // We're in package @modelcontextprotocol/inspector-cli + const pathB = "../../package.json"; // We're in package @modelcontextprotocol/inspector + let packageJson: { name: string; version: string }; + let packageJsonData = await import(fs.existsSync(pathA) ? pathA : pathB, { + with: { type: "json" }, + }); + packageJson = packageJsonData.default; + const transportOptions = createTransportOptions( args.target, args.transport, args.headers, ); const transport = createTransport(transportOptions); - const client = new Client({ - name: "inspector-cli", - version: "0.5.1", - }); + + const [, name = packageJson.name] = packageJson.name.split("/"); + const version = packageJson.version; + const clientIdentity = { name, version }; + + const client = new Client(clientIdentity); try { await connect(client, transport); diff --git a/client/package.json b/client/package.json index 61f65b62..6881c1f7 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.16.6", + "version": "0.17.0", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -25,7 +25,7 @@ "cleanup:e2e": "node e2e/global-teardown.js" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.5", + "@modelcontextprotocol/sdk": "^1.18.0", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", @@ -33,6 +33,7 @@ "@radix-ui/react-popover": "^1.1.3", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", @@ -69,6 +70,7 @@ "globals": "^15.9.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "jest-fixed-jsdom": "^0.0.9", "postcss": "^8.5.6", "tailwindcss": "^3.4.13", "tailwindcss-animate": "^1.0.7", diff --git a/client/src/App.tsx b/client/src/App.tsx index 60ce90c5..5937d738 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -22,6 +22,8 @@ import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants"; import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types"; import { OAuthStateMachine } from "./lib/oauth-state-machine"; import { cacheToolOutputSchemas } from "./utils/schemaUtils"; +import { cleanParams } from "./utils/paramUtils"; +import type { JsonSchemaType } from "./utils/jsonUtils"; import React, { Suspense, useCallback, @@ -74,6 +76,10 @@ import ElicitationTab, { PendingElicitationRequest, ElicitationResponse, } from "./components/ElicitationTab"; +import { + CustomHeaders, + migrateFromLegacyAuth, +} from "./lib/types/customHeaders"; const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; @@ -103,6 +109,14 @@ const App = () => { const [transportType, setTransportType] = useState< "stdio" | "sse" | "streamable-http" >(getInitialTransportType); + const [connectionType, setConnectionType] = useState<"direct" | "proxy">( + () => { + return ( + (localStorage.getItem("lastConnectionType") as "direct" | "proxy") || + "proxy" + ); + }, + ); const [logLevel, setLogLevel] = useState("debug"); const [notifications, setNotifications] = useState([]); const [roots, setRoots] = useState([]); @@ -127,6 +141,43 @@ const App = () => { return localStorage.getItem("lastOauthScope") || ""; }); + const [oauthClientSecret, setOauthClientSecret] = useState(() => { + return localStorage.getItem("lastOauthClientSecret") || ""; + }); + + // Custom headers state with migration from legacy auth + const [customHeaders, setCustomHeaders] = useState(() => { + const savedHeaders = localStorage.getItem("lastCustomHeaders"); + if (savedHeaders) { + try { + return JSON.parse(savedHeaders); + } catch (error) { + console.warn( + `Failed to parse custom headers: "${savedHeaders}", will try legacy migration`, + error, + ); + // Fall back to migration if JSON parsing fails + } + } + + // Migrate from legacy auth if available + const legacyToken = localStorage.getItem("lastBearerToken") || ""; + const legacyHeaderName = localStorage.getItem("lastHeaderName") || ""; + + if (legacyToken) { + return migrateFromLegacyAuth(legacyToken, legacyHeaderName); + } + + // Default to empty array + return [ + { + name: "Authorization", + value: "Bearer ", + enabled: false, + }, + ]; + }); + const [pendingSampleRequests, setPendingSampleRequests] = useState< Array< PendingRequest & { @@ -213,11 +264,12 @@ const App = () => { args, sseUrl, env, - bearerToken, - headerName, + customHeaders, oauthClientId, + oauthClientSecret, oauthScope, config, + connectionType, onNotification: (notification) => { setNotifications((prev) => [...prev, notification as ServerNotification]); }, @@ -303,13 +355,42 @@ const App = () => { }, [transportType]); useEffect(() => { - localStorage.setItem("lastBearerToken", bearerToken); + localStorage.setItem("lastConnectionType", connectionType); + }, [connectionType]); + + useEffect(() => { + if (bearerToken) { + localStorage.setItem("lastBearerToken", bearerToken); + } else { + localStorage.removeItem("lastBearerToken"); + } }, [bearerToken]); useEffect(() => { - localStorage.setItem("lastHeaderName", headerName); + if (headerName) { + localStorage.setItem("lastHeaderName", headerName); + } else { + localStorage.removeItem("lastHeaderName"); + } }, [headerName]); + useEffect(() => { + localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders)); + }, [customHeaders]); + + // Auto-migrate from legacy auth when custom headers are empty but legacy auth exists + useEffect(() => { + if (customHeaders.length === 0 && (bearerToken || headerName)) { + const migratedHeaders = migrateFromLegacyAuth(bearerToken, headerName); + if (migratedHeaders.length > 0) { + setCustomHeaders(migratedHeaders); + // Clear legacy auth after migration + setBearerToken(""); + setHeaderName(""); + } + } + }, [bearerToken, headerName, customHeaders, setCustomHeaders]); + useEffect(() => { localStorage.setItem("lastOauthClientId", oauthClientId); }, [oauthClientId]); @@ -318,6 +399,10 @@ const App = () => { localStorage.setItem("lastOauthScope", oauthScope); }, [oauthScope]); + useEffect(() => { + localStorage.setItem("lastOauthClientSecret", oauthClientSecret); + }, [oauthClientSecret]); + useEffect(() => { saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config); }, [config]); @@ -703,12 +788,18 @@ const App = () => { lastToolCallOriginTabRef.current = currentTabRef.current; try { + // Find the tool schema to clean parameters properly + const tool = tools.find((t) => t.name === name); + const cleanedParams = tool?.inputSchema + ? cleanParams(params, tool.inputSchema as JsonSchemaType) + : params; + const response = await sendMCPRequest( { method: "tools/call" as const, params: { name, - arguments: params, + arguments: cleanedParams, _meta: { progressToken: progressTokenRef.current++, }, @@ -719,6 +810,8 @@ const App = () => { ); setToolResult(response); + // Clear any validation errors since tool execution completed + setErrors((prev) => ({ ...prev, tools: null })); } catch (e) { const toolResult: CompatibilityCallToolResult = { content: [ @@ -730,6 +823,8 @@ const App = () => { isError: true, }; setToolResult(toolResult); + // Clear validation errors - tool execution errors are shown in ToolResults + setErrors((prev) => ({ ...prev, tools: null })); } }; @@ -810,12 +905,12 @@ const App = () => { setEnv={setEnv} config={config} setConfig={setConfig} - bearerToken={bearerToken} - setBearerToken={setBearerToken} - headerName={headerName} - setHeaderName={setHeaderName} + customHeaders={customHeaders} + setCustomHeaders={setCustomHeaders} oauthClientId={oauthClientId} setOauthClientId={setOauthClientId} + oauthClientSecret={oauthClientSecret} + setOauthClientSecret={setOauthClientSecret} oauthScope={oauthScope} setOauthScope={setOauthScope} onConnect={connectMcpServer} @@ -823,6 +918,8 @@ const App = () => { logLevel={logLevel} sendLogLevelRequest={sendLogLevelRequest} loggingSupported={!!serverCapabilities?.logging || false} + connectionType={connectionType} + setConnectionType={setConnectionType} />
void; + className?: string; +} + +const CustomHeaders = ({ + headers, + onChange, + className, +}: CustomHeadersProps) => { + const [isJsonMode, setIsJsonMode] = useState(false); + const [jsonValue, setJsonValue] = useState(""); + const [jsonError, setJsonError] = useState(null); + const [visibleValues, setVisibleValues] = useState>(new Set()); + + const updateHeader = ( + index: number, + field: keyof CustomHeader, + value: string | boolean, + ) => { + const newHeaders = [...headers]; + newHeaders[index] = { ...newHeaders[index], [field]: value }; + onChange(newHeaders); + }; + + const addHeader = () => { + onChange([...headers, createEmptyHeader()]); + }; + + const removeHeader = (index: number) => { + const newHeaders = headers.filter((_, i) => i !== index); + onChange(newHeaders); + }; + + const toggleValueVisibility = (index: number) => { + const newVisible = new Set(visibleValues); + if (newVisible.has(index)) { + newVisible.delete(index); + } else { + newVisible.add(index); + } + setVisibleValues(newVisible); + }; + + const switchToJsonMode = () => { + const jsonObject: Record = {}; + headers.forEach((header) => { + if (header.enabled && header.name.trim() && header.value.trim()) { + jsonObject[header.name.trim()] = header.value.trim(); + } + }); + setJsonValue(JSON.stringify(jsonObject, null, 2)); + setJsonError(null); + setIsJsonMode(true); + }; + + const switchToFormMode = () => { + try { + const parsed = JSON.parse(jsonValue); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + setJsonError("JSON must be an object with string key-value pairs"); + return; + } + + const newHeaders: CustomHeadersType = Object.entries(parsed).map( + ([name, value]) => ({ + name, + value: String(value), + enabled: true, + }), + ); + + onChange(newHeaders); + setJsonError(null); + setIsJsonMode(false); + } catch { + setJsonError("Invalid JSON format"); + } + }; + + const handleJsonChange = (value: string) => { + setJsonValue(value); + setJsonError(null); + }; + + if (isJsonMode) { + return ( +
+
+

+ Custom Headers (JSON) +

+ +
+
+