diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b225713..ed28e9d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,8 +12,8 @@ Thanks for your interest in contributing! This guide explains how to get involve ## Development Process & Pull Requests 1. Create a new branch for your changes -2. Make your changes following existing code style and conventions -3. Test changes locally +2. Make your changes following existing code style and conventions. You can run `npm run prettier-check` and `npm run prettier-fix` as applicable. +3. Test changes locally by running `npm test` 4. Update documentation as needed 5. Use clear commit messages explaining your changes 6. Verify all changes work as expected diff --git a/README.md b/README.md index f1bd97c..5fe3693 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,14 @@ The inspector supports bearer token authentication for SSE connections. Enter yo 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. +### Configuration + +The MCP Inspector supports the following configuration settings. To change them click on the `Configuration` button in the MCP Inspector UI : + +| Name | Purpose | Default Value | +| -------------------------- | ----------------------------------------------------------------------------------------- | ------------- | +| MCP_SERVER_REQUEST_TIMEOUT | Maximum time in milliseconds to wait for a response from the MCP server before timing out | 10000 | + ### From this repository If you're working on the inspector itself: diff --git a/client/src/App.tsx b/client/src/App.tsx index 0b0a3e1..c29ef71 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -45,23 +45,16 @@ import RootsTab from "./components/RootsTab"; import SamplingTab, { PendingRequest } from "./components/SamplingTab"; import Sidebar from "./components/Sidebar"; import ToolsTab from "./components/ToolsTab"; +import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants"; +import { InspectorConfig } from "./lib/configurationTypes"; const params = new URLSearchParams(window.location.search); const PROXY_PORT = params.get("proxyPort") ?? "3000"; const PROXY_SERVER_URL = `http://${window.location.hostname}:${PROXY_PORT}`; +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[] @@ -99,6 +92,11 @@ const App = () => { >([]); const [roots, setRoots] = useState([]); const [env, setEnv] = useState>({}); + + const [config, setConfig] = useState(() => { + const savedConfig = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY); + return savedConfig ? JSON.parse(savedConfig) : DEFAULT_INSPECTOR_CONFIG; + }); const [bearerToken, setBearerToken] = useState(() => { return localStorage.getItem("lastBearerToken") || ""; }); @@ -114,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, ); @@ -171,6 +153,7 @@ const App = () => { env, bearerToken, proxyServerUrl: PROXY_SERVER_URL, + requestTimeout: config.MCP_SERVER_REQUEST_TIMEOUT.value as number, onNotification: (notification) => { setNotifications((prev) => [...prev, notification as ServerNotification]); }, @@ -209,6 +192,10 @@ const App = () => { localStorage.setItem("lastBearerToken", bearerToken); }, [bearerToken]); + useEffect(() => { + localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config)); + }, [config]); + // Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback) useEffect(() => { const serverUrl = params.get("serverUrl"); @@ -224,7 +211,7 @@ const App = () => { // Connect to the server connectMcpServer(); } - }, []); + }, [connectMcpServer]); useEffect(() => { fetch(`${PROXY_SERVER_URL}/config`) @@ -253,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 })); }; @@ -425,6 +428,17 @@ const App = () => { setLogLevel(level); }; + if (window.location.pathname === "/oauth/callback") { + const OAuthCallback = React.lazy( + () => import("./components/OAuthCallback"), + ); + return ( + Loading...}> + + + ); + } + return (
{ setSseUrl={setSseUrl} env={env} setEnv={setEnv} + config={config} + setConfig={setConfig} bearerToken={bearerToken} setBearerToken={setBearerToken} onConnect={connectMcpServer} diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 7da1762..f5b0d63 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -108,6 +108,21 @@ const DynamicJsonForm = ({ } }; + const formatJson = () => { + 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 && ( -