diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ad5699f..b2257132 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 a6ab6d4c..f1bd97ce 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 8e0318d1..1b744ced 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 3830e792..c360e72b 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 0471765d..9eff88c5 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 f1d140a5..c29ef71b 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 532d3b1b..b03d1f44 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 0d2596eb..215d66b7 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 f88b16f8..b42cf77e 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 && ( -