diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ad5699..b225713 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ Thanks for your interest in contributing! This guide explains how to get involve 1. Fork the repository and clone it locally 2. Install dependencies with `npm install` 3. Run `npm run dev` to start both client and server in development mode -4. Use the web UI at http://localhost:5173 to interact with the inspector +4. Use the web UI at http://127.0.0.1:5173 to interact with the inspector ## Development Process & Pull Requests diff --git a/README.md b/README.md index a6ab6d4..f1bd97c 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ For more details on ways to use the inspector, see the [Inspector section of the The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header. +### Security Considerations + +The MCP Inspector includes a proxy server that can run and communicate with local MCP processes. The proxy server should not be exposed to untrusted networks as it has permissions to spawn local processes and can connect to any specified MCP server. + ### From this repository If you're working on the inspector itself: diff --git a/bin/cli.js b/bin/cli.js index 8e0318d..1b744ce 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -102,7 +102,7 @@ async function main() { await Promise.any([server, client, delay(2 * 1000)]); const portParam = SERVER_PORT === "3000" ? "" : `?proxyPort=${SERVER_PORT}`; console.log( - `\nšŸ” MCP Inspector is up and running at http://localhost:${CLIENT_PORT}${portParam} šŸš€`, + `\nšŸ” MCP Inspector is up and running at http://127.0.0.1:${CLIENT_PORT}${portParam} šŸš€`, ); try { diff --git a/client/jest.config.cjs b/client/jest.config.cjs index 3830e79..c360e72 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -3,16 +3,12 @@ module.exports = { testEnvironment: "jsdom", moduleNameMapper: { "^@/(.*)$": "/src/$1", - "^../components/DynamicJsonForm$": - "/src/utils/__mocks__/DynamicJsonForm.ts", - "^../../components/DynamicJsonForm$": - "/src/utils/__mocks__/DynamicJsonForm.ts", + "\\.css$": "/src/__mocks__/styleMock.js", }, transform: { "^.+\\.tsx?$": [ "ts-jest", { - useESM: true, jsx: "react-jsx", tsconfig: "tsconfig.jest.json", }, diff --git a/client/package.json b/client/package.json index 0471765..9eff88c 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.6.0", + "version": "0.7.0", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -24,8 +24,8 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.6.1", - "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.3", @@ -38,7 +38,7 @@ "cmdk": "^1.0.4", "lucide-react": "^0.447.0", "pkce-challenge": "^4.1.0", - "prismjs": "^1.29.0", + "prismjs": "^1.30.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-simple-code-editor": "^0.14.1", @@ -50,6 +50,8 @@ }, "devDependencies": { "@eslint/js": "^9.11.1", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", "@types/jest": "^29.5.14", "@types/node": "^22.7.5", "@types/react": "^18.3.10", diff --git a/client/src/App.tsx b/client/src/App.tsx index f1d140a..c29ef71 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -55,16 +55,6 @@ const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1"; const App = () => { // Handle OAuth callback route - if (window.location.pathname === "/oauth/callback") { - const OAuthCallback = React.lazy( - () => import("./components/OAuthCallback"), - ); - return ( - Loading...}> - - - ); - } const [resources, setResources] = useState([]); const [resourceTemplates, setResourceTemplates] = useState< ResourceTemplate[] @@ -122,22 +112,6 @@ const App = () => { const nextRequestId = useRef(0); const rootsRef = useRef([]); - const handleApproveSampling = (id: number, result: CreateMessageResult) => { - setPendingSampleRequests((prev) => { - const request = prev.find((r) => r.id === id); - request?.resolve(result); - return prev.filter((r) => r.id !== id); - }); - }; - - const handleRejectSampling = (id: number) => { - setPendingSampleRequests((prev) => { - const request = prev.find((r) => r.id === id); - request?.reject(new Error("Sampling request rejected")); - return prev.filter((r) => r.id !== id); - }); - }; - const [selectedResource, setSelectedResource] = useState( null, ); @@ -237,7 +211,7 @@ const App = () => { // Connect to the server connectMcpServer(); } - }, []); + }, [connectMcpServer]); useEffect(() => { fetch(`${PROXY_SERVER_URL}/config`) @@ -266,6 +240,22 @@ const App = () => { } }, []); + const handleApproveSampling = (id: number, result: CreateMessageResult) => { + setPendingSampleRequests((prev) => { + const request = prev.find((r) => r.id === id); + request?.resolve(result); + return prev.filter((r) => r.id !== id); + }); + }; + + const handleRejectSampling = (id: number) => { + setPendingSampleRequests((prev) => { + const request = prev.find((r) => r.id === id); + request?.reject(new Error("Sampling request rejected")); + return prev.filter((r) => r.id !== id); + }); + }; + const clearError = (tabKey: keyof typeof errors) => { setErrors((prev) => ({ ...prev, [tabKey]: null })); }; @@ -438,6 +428,17 @@ const App = () => { setLogLevel(level); }; + if (window.location.pathname === "/oauth/callback") { + const OAuthCallback = React.lazy( + () => import("./components/OAuthCallback"), + ); + return ( + Loading...}> + + + ); + } + return (
{ + try { + const jsonStr = rawJsonValue.trim(); + if (!jsonStr) { + return; + } + const formatted = JSON.stringify(JSON.parse(jsonStr), null, 2); + setRawJsonValue(formatted); + debouncedUpdateParent(formatted); + setJsonError(undefined); + } catch (err) { + setJsonError(err instanceof Error ? err.message : "Invalid JSON"); + } + }; + const renderFormFields = ( propSchema: JsonSchemaType, currentValue: JsonValue, @@ -353,7 +368,12 @@ const DynamicJsonForm = ({ return (
-
+
+ {isJsonMode && ( + + )} diff --git a/client/src/components/History.tsx b/client/src/components/History.tsx index 532d3b1..b03d1f4 100644 --- a/client/src/components/History.tsx +++ b/client/src/components/History.tsx @@ -1,6 +1,7 @@ import { ServerNotification } from "@modelcontextprotocol/sdk/types.js"; import { Copy } from "lucide-react"; import { useState } from "react"; +import JsonView from "./JsonView"; const HistoryAndNotifications = ({ requestHistory, @@ -74,9 +75,9 @@ const HistoryAndNotifications = ({
-
-                          {JSON.stringify(JSON.parse(request.request), null, 2)}
-                        
+
+ +
{request.response && (
@@ -91,13 +92,9 @@ const HistoryAndNotifications = ({
-
-                            {JSON.stringify(
-                              JSON.parse(request.response),
-                              null,
-                              2,
-                            )}
-                          
+
+ +
)} @@ -146,9 +143,11 @@ const HistoryAndNotifications = ({
-
-                        {JSON.stringify(notification, null, 2)}
-                      
+
+ +
)} diff --git a/client/src/components/JsonEditor.tsx b/client/src/components/JsonEditor.tsx index 0d2596e..215d66b 100644 --- a/client/src/components/JsonEditor.tsx +++ b/client/src/components/JsonEditor.tsx @@ -3,7 +3,6 @@ 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; @@ -16,49 +15,25 @@ const JsonEditor = ({ onChange, error: externalError, }: JsonEditorProps) => { - const [editorContent, setEditorContent] = useState(value); + const [editorContent, setEditorContent] = useState(value || ""); const [internalError, setInternalError] = useState( undefined, ); useEffect(() => { - setEditorContent(value); + setEditorContent(value || ""); }, [value]); - const formatJson = (json: string): string => { - try { - return JSON.stringify(JSON.parse(json), null, 2); - } catch { - return json; - } - }; - const handleEditorChange = (newContent: string) => { setEditorContent(newContent); setInternalError(undefined); onChange(newContent); }; - const handleFormatJson = () => { - try { - const formatted = formatJson(editorContent); - setEditorContent(formatted); - onChange(formatted); - setInternalError(undefined); - } catch (err) { - setInternalError(err instanceof Error ? err.message : "Invalid JSON"); - } - }; - const displayError = internalError || externalError; return ( -
-
- -
+
{ + const normalizedData = + typeof data === "string" + ? tryParseJson(data).success + ? tryParseJson(data).data + : data + : data; + + return ( +
+ +
+ ); + }, +); + +JsonView.displayName = "JsonView"; + +interface JsonNodeProps { + data: JsonValue; + name?: string; + depth: number; + initialExpandDepth: number; +} + +const JsonNode = memo( + ({ data, name, depth = 0, initialExpandDepth }: JsonNodeProps) => { + const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth); + + const getDataType = (value: JsonValue): string => { + if (Array.isArray(value)) return "array"; + if (value === null) return "null"; + return typeof value; + }; + + const dataType = getDataType(data); + + const typeStyleMap: Record = { + number: "text-blue-600", + boolean: "text-amber-600", + null: "text-purple-600", + undefined: "text-gray-600", + string: "text-green-600 break-all whitespace-pre-wrap", + default: "text-gray-700", + }; + + const renderCollapsible = (isArray: boolean) => { + const items = isArray + ? (data as JsonValue[]) + : Object.entries(data as Record); + const itemCount = items.length; + const isEmpty = itemCount === 0; + + const symbolMap = { + open: isArray ? "[" : "{", + close: isArray ? "]" : "}", + collapsed: isArray ? "[ ... ]" : "{ ... }", + empty: isArray ? "[]" : "{}", + }; + + if (isEmpty) { + return ( +
+ {name && ( + + {name}: + + )} + {symbolMap.empty} +
+ ); + } + + return ( +
+
setIsExpanded(!isExpanded)} + > + {name && ( + + {name}: + + )} + {isExpanded ? ( + + {symbolMap.open} + + ) : ( + <> + + {symbolMap.collapsed} + + + {itemCount} {itemCount === 1 ? "item" : "items"} + + + )} +
+ {isExpanded && ( + <> +
+ {isArray + ? (items as JsonValue[]).map((item, index) => ( +
+ +
+ )) + : (items as [string, JsonValue][]).map(([key, value]) => ( +
+ +
+ ))} +
+
+ {symbolMap.close} +
+ + )} +
+ ); + }; + + const renderString = (value: string) => { + const maxLength = 100; + const isTooLong = value.length > maxLength; + + if (!isTooLong) { + return ( +
+ {name && ( + + {name}: + + )} +
"{value}"
+
+ ); + } + + return ( +
+ {name && ( + + {name}: + + )} +
 setIsExpanded(!isExpanded)}
+            title={isExpanded ? "Click to collapse" : "Click to expand"}
+          >
+            {isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
+          
+
+ ); + }; + + switch (dataType) { + case "object": + case "array": + return renderCollapsible(dataType === "array"); + case "string": + return renderString(data as string); + default: + return ( +
+ {name && ( + + {name}: + + )} + + {data === null ? "null" : String(data)} + +
+ ); + } + }, +); + +JsonNode.displayName = "JsonNode"; + +export default JsonView; diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx index f88b16f..b42cf77 100644 --- a/client/src/components/PromptsTab.tsx +++ b/client/src/components/PromptsTab.tsx @@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button"; import { Combobox } from "@/components/ui/combobox"; import { Label } from "@/components/ui/label"; import { TabsContent } from "@/components/ui/tabs"; -import { Textarea } from "@/components/ui/textarea"; + import { ListPromptsResult, PromptReference, @@ -13,6 +13,7 @@ import { AlertCircle } from "lucide-react"; import { useEffect, useState } from "react"; import ListPane from "./ListPane"; import { useCompletionState } from "@/lib/hooks/useCompletionState"; +import JsonView from "./JsonView"; export type Prompt = { name: string; @@ -151,11 +152,9 @@ const PromptsTab = ({ Get Prompt {promptContent && ( -