diff --git a/README.md b/README.md index 98b5704..a6da9f4 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The MCP inspector is a developer tool for testing and debugging MCP servers. To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`: ```bash -npx @modelcontextprotocol/inspector build/index.js +npx @modelcontextprotocol/inspector node build/index.js ``` You can pass both arguments and environment variables to your MCP server. Arguments are passed directly to your server, while environment variables can be set using the `-e` flag: @@ -21,19 +21,19 @@ You can pass both arguments and environment variables to your MCP server. Argume npx @modelcontextprotocol/inspector build/index.js arg1 arg2 # Pass environment variables only -npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 build/index.js +npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js # Pass both environment variables and arguments -npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 build/index.js arg1 arg2 +npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js arg1 arg2 # Use -- to separate inspector flags from server arguments -npx @modelcontextprotocol/inspector -e KEY=$VALUE -- build/index.js -e server-flag +npx @modelcontextprotocol/inspector -e KEY=$VALUE -- node build/index.js -e server-flag ``` The inspector runs both a client UI (default port 5173) and an MCP proxy server (default port 3000). Open the client UI in your browser to use the inspector. You can customize the ports if needed: ```bash -CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector build/index.js +CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js ``` For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging). @@ -48,6 +48,13 @@ Development mode: npm run dev ``` +> **Note for Windows users:** +> On Windows, use the following command instead: +> +> ```bash +> npm run dev:windows +> ``` + Production mode: ```bash diff --git a/client/bin/cli.js b/client/bin/cli.js index ae4967f..7dc93ea 100755 --- a/client/bin/cli.js +++ b/client/bin/cli.js @@ -9,7 +9,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const distPath = join(__dirname, "../dist"); const server = http.createServer((request, response) => { - return handler(request, response, { public: distPath }); + return handler(request, response, { + public: distPath, + rewrites: [{ source: "/**", destination: "/index.html" }], + }); }); const port = process.env.PORT || 5173; diff --git a/client/package.json b/client/package.json index 69021c8..4e95b83 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.4.0", + "version": "0.4.1", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -22,17 +22,21 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.4.1", + "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.1", + "@types/prismjs": "^1.26.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "lucide-react": "^0.447.0", + "prismjs": "^1.29.0", "pkce-challenge": "^4.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-simple-code-editor": "^0.14.1", "react-toastify": "^10.0.6", "serve-handler": "^6.1.6", "tailwind-merge": "^2.5.3", diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx new file mode 100644 index 0000000..ff26118 --- /dev/null +++ b/client/src/components/DynamicJsonForm.tsx @@ -0,0 +1,269 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import JsonEditor from "./JsonEditor"; + +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +export type JsonSchemaType = { + type: "string" | "number" | "integer" | "boolean" | "array" | "object"; + description?: string; + properties?: Record; + items?: JsonSchemaType; +}; + +type JsonObject = { [key: string]: JsonValue }; + +interface DynamicJsonFormProps { + schema: JsonSchemaType; + value: JsonValue; + onChange: (value: JsonValue) => void; + maxDepth?: number; +} + +const formatFieldLabel = (key: string): string => { + return key + .replace(/([A-Z])/g, " $1") // Insert space before capital letters + .replace(/_/g, " ") // Replace underscores with spaces + .replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter +}; + +const DynamicJsonForm = ({ + schema, + value, + onChange, + maxDepth = 3, +}: DynamicJsonFormProps) => { + const [isJsonMode, setIsJsonMode] = useState(false); + const [jsonError, setJsonError] = useState(); + + const generateDefaultValue = (propSchema: JsonSchemaType): JsonValue => { + switch (propSchema.type) { + case "string": + return ""; + case "number": + case "integer": + return 0; + case "boolean": + return false; + case "array": + return []; + case "object": { + const obj: JsonObject = {}; + if (propSchema.properties) { + Object.entries(propSchema.properties).forEach(([key, prop]) => { + obj[key] = generateDefaultValue(prop); + }); + } + return obj; + } + default: + return null; + } + }; + + const renderFormFields = ( + propSchema: JsonSchemaType, + currentValue: JsonValue, + path: string[] = [], + depth: number = 0, + ) => { + if ( + depth >= maxDepth && + (propSchema.type === "object" || propSchema.type === "array") + ) { + // Render as JSON editor when max depth is reached + return ( + { + try { + const parsed = JSON.parse(newValue); + handleFieldChange(path, parsed); + setJsonError(undefined); + } catch (err) { + setJsonError(err instanceof Error ? err.message : "Invalid JSON"); + } + }} + error={jsonError} + /> + ); + } + + switch (propSchema.type) { + case "string": + case "number": + case "integer": + return ( + + handleFieldChange( + path, + propSchema.type === "string" + ? e.target.value + : Number(e.target.value), + ) + } + placeholder={propSchema.description} + /> + ); + case "boolean": + return ( + handleFieldChange(path, e.target.checked)} + className="w-4 h-4" + /> + ); + case "object": + if (!propSchema.properties) return null; + return ( +
+ {Object.entries(propSchema.properties).map(([key, prop]) => ( +
+ + {renderFormFields( + prop, + (currentValue as JsonObject)?.[key], + [...path, key], + depth + 1, + )} +
+ ))} +
+ ); + case "array": { + const arrayValue = Array.isArray(currentValue) ? currentValue : []; + if (!propSchema.items) return null; + return ( +
+ {propSchema.description && ( +

{propSchema.description}

+ )} + + {propSchema.items?.description && ( +

+ Items: {propSchema.items.description} +

+ )} + +
+ {arrayValue.map((item, index) => ( +
+ {renderFormFields( + propSchema.items as JsonSchemaType, + item, + [...path, index.toString()], + depth + 1, + )} + +
+ ))} + +
+
+ ); + } + default: + return null; + } + }; + + const handleFieldChange = (path: string[], fieldValue: JsonValue) => { + if (path.length === 0) { + onChange(fieldValue); + return; + } + + const newValue = { + ...(typeof value === "object" && value !== null && !Array.isArray(value) + ? value + : {}), + } as JsonObject; + let current: JsonObject = newValue; + + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + if (!(key in current)) { + current[key] = {}; + } + current = current[key] as JsonObject; + } + + current[path[path.length - 1]] = fieldValue; + onChange(newValue); + }; + + return ( +
+
+ +
+ + {isJsonMode ? ( + { + try { + onChange(JSON.parse(newValue)); + setJsonError(undefined); + } catch (err) { + setJsonError(err instanceof Error ? err.message : "Invalid JSON"); + } + }} + error={jsonError} + /> + ) : ( + renderFormFields(schema, value) + )} +
+ ); +}; + +export default DynamicJsonForm; diff --git a/client/src/components/JsonEditor.tsx b/client/src/components/JsonEditor.tsx new file mode 100644 index 0000000..2fb7e26 --- /dev/null +++ b/client/src/components/JsonEditor.tsx @@ -0,0 +1,59 @@ +import Editor from "react-simple-code-editor"; +import Prism from "prismjs"; +import "prismjs/components/prism-json"; +import "prismjs/themes/prism.css"; +import { Button } from "@/components/ui/button"; + +interface JsonEditorProps { + value: string; + onChange: (value: string) => void; + error?: string; +} + +const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => { + const formatJson = (json: string): string => { + try { + return JSON.stringify(JSON.parse(json), null, 2); + } catch { + return json; + } + }; + + return ( +
+
+ +
+
+ + Prism.highlight(code, Prism.languages.json, "json") + } + padding={10} + style={{ + fontFamily: '"Fira code", "Fira Mono", monospace', + fontSize: 14, + backgroundColor: "transparent", + minHeight: "100px", + }} + className="w-full" + /> +
+ {error &&

{error}

} +
+ ); +}; + +export default JsonEditor; diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index a3a7ff2..418e58e 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -1,9 +1,11 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { TabsContent } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; +import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm"; import { ListToolsResult, Tool, @@ -15,6 +17,12 @@ import ListPane from "./ListPane"; import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js"; +type SchemaProperty = { + type: string; + description?: string; + properties?: Record; +}; + const ToolsTab = ({ tools, listTools, @@ -159,22 +167,42 @@ const ToolsTab = ({ {selectedTool.description}

{Object.entries(selectedTool.inputSchema.properties ?? []).map( - ([key, value]) => ( -
- - { - /* @ts-expect-error value type is currently unknown */ - value.type === "string" ? ( + ([key, value]) => { + const prop = value as SchemaProperty; + return ( +
+ + {prop.type === "boolean" ? ( +
+ + setParams({ + ...params, + [key]: checked, + }) + } + /> + +
+ ) : prop.type === "string" ? (