diff --git a/README.md b/README.md index c43f943..0db92bf 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The MCP inspector is a developer tool for testing and debugging MCP servers. -![MCP Inspector Screenshot](mcp-inspector.png) +![MCP Inspector Screenshot](https://raw.githubusercontent.com/modelcontextprotocol/inspector/main/mcp-inspector.png) ## Running the Inspector @@ -48,12 +48,16 @@ The MCP Inspector includes a proxy server that can run and communicate with loca ### Configuration -The MCP Inspector supports the following configuration settings. To change them click on the `Configuration` button in the MCP Inspector UI : +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 | -| MCP_PROXY_FULL_ADDRESS | The full URL of the MCP Inspector proxy server (e.g. `http://10.2.1.14:2277`) | `null` | +| Setting | Description | Default | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------- | +| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 | +| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true | +| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 | +| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" | + +These settings can be adjusted in real-time through the UI and will persist across sessions. ### From this repository diff --git a/client/package.json b/client/package.json index f43ab23..0734ea9 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.8.1", + "version": "0.8.2", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -23,7 +23,7 @@ "test:watch": "jest --config jest.config.cjs --watch" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.8.0", + "@modelcontextprotocol/sdk": "^1.9.0", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", @@ -32,9 +32,8 @@ "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.1", - "@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-toast": "^1.2.6", - "@types/prismjs": "^1.26.5", + "@radix-ui/react-tooltip": "^1.1.8", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.4", @@ -55,6 +54,7 @@ "@testing-library/react": "^16.2.0", "@types/jest": "^29.5.14", "@types/node": "^22.7.5", + "@types/prismjs": "^1.26.5", "@types/react": "^18.3.10", "@types/react-dom": "^18.3.0", "@types/serve-handler": "^6.1.4", diff --git a/client/src/App.tsx b/client/src/App.tsx index 7564544..4f99ffd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -45,10 +45,7 @@ import Sidebar from "./components/Sidebar"; import ToolsTab from "./components/ToolsTab"; import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants"; import { InspectorConfig } from "./lib/configurationTypes"; -import { - getMCPProxyAddress, - getMCPServerRequestTimeout, -} from "./utils/configUtils"; +import { getMCPProxyAddress } from "./utils/configUtils"; import { useToast } from "@/hooks/use-toast"; const params = new URLSearchParams(window.location.search); @@ -98,10 +95,21 @@ const App = () => { const [config, setConfig] = useState(() => { const savedConfig = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY); if (savedConfig) { - return { + // merge default config with saved config + const mergedConfig = { ...DEFAULT_INSPECTOR_CONFIG, ...JSON.parse(savedConfig), } as InspectorConfig; + + // update description of keys to match the new description (in case of any updates to the default config description) + Object.entries(mergedConfig).forEach(([key, value]) => { + mergedConfig[key as keyof InspectorConfig] = { + ...value, + label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label, + }; + }); + + return mergedConfig; } return DEFAULT_INSPECTOR_CONFIG; }); @@ -148,7 +156,7 @@ const App = () => { serverCapabilities, mcpClient, requestHistory, - makeRequest: makeConnectionRequest, + makeRequest, sendNotification, handleCompletion, completionsSupported, @@ -161,8 +169,7 @@ const App = () => { sseUrl, env, bearerToken, - proxyServerUrl: getMCPProxyAddress(config), - requestTimeout: getMCPServerRequestTimeout(config), + config, onNotification: (notification) => { setNotifications((prev) => [...prev, notification as ServerNotification]); }, @@ -279,13 +286,13 @@ const App = () => { setErrors((prev) => ({ ...prev, [tabKey]: null })); }; - const makeRequest = async ( + const sendMCPRequest = async ( request: ClientRequest, schema: T, tabKey?: keyof typeof errors, ) => { try { - const response = await makeConnectionRequest(request, schema); + const response = await makeRequest(request, schema); if (tabKey !== undefined) { clearError(tabKey); } @@ -303,7 +310,7 @@ const App = () => { }; const listResources = async () => { - const response = await makeRequest( + const response = await sendMCPRequest( { method: "resources/list" as const, params: nextResourceCursor ? { cursor: nextResourceCursor } : {}, @@ -316,7 +323,7 @@ const App = () => { }; const listResourceTemplates = async () => { - const response = await makeRequest( + const response = await sendMCPRequest( { method: "resources/templates/list" as const, params: nextResourceTemplateCursor @@ -333,7 +340,7 @@ const App = () => { }; const readResource = async (uri: string) => { - const response = await makeRequest( + const response = await sendMCPRequest( { method: "resources/read" as const, params: { uri }, @@ -346,7 +353,7 @@ const App = () => { const subscribeToResource = async (uri: string) => { if (!resourceSubscriptions.has(uri)) { - await makeRequest( + await sendMCPRequest( { method: "resources/subscribe" as const, params: { uri }, @@ -362,7 +369,7 @@ const App = () => { const unsubscribeFromResource = async (uri: string) => { if (resourceSubscriptions.has(uri)) { - await makeRequest( + await sendMCPRequest( { method: "resources/unsubscribe" as const, params: { uri }, @@ -377,7 +384,7 @@ const App = () => { }; const listPrompts = async () => { - const response = await makeRequest( + const response = await sendMCPRequest( { method: "prompts/list" as const, params: nextPromptCursor ? { cursor: nextPromptCursor } : {}, @@ -390,7 +397,7 @@ const App = () => { }; const getPrompt = async (name: string, args: Record = {}) => { - const response = await makeRequest( + const response = await sendMCPRequest( { method: "prompts/get" as const, params: { name, arguments: args }, @@ -402,7 +409,7 @@ const App = () => { }; const listTools = async () => { - const response = await makeRequest( + const response = await sendMCPRequest( { method: "tools/list" as const, params: nextToolCursor ? { cursor: nextToolCursor } : {}, @@ -415,21 +422,34 @@ const App = () => { }; const callTool = async (name: string, params: Record) => { - const response = await makeRequest( - { - method: "tools/call" as const, - params: { - name, - arguments: params, - _meta: { - progressToken: progressTokenRef.current++, + try { + const response = await sendMCPRequest( + { + method: "tools/call" as const, + params: { + name, + arguments: params, + _meta: { + progressToken: progressTokenRef.current++, + }, }, }, - }, - CompatibilityCallToolResultSchema, - "tools", - ); - setToolResult(response); + CompatibilityCallToolResultSchema, + "tools", + ); + setToolResult(response); + } catch (e) { + const toolResult: CompatibilityCallToolResult = { + content: [ + { + type: "text", + text: (e as Error).message ?? String(e), + }, + ], + isError: true, + }; + setToolResult(toolResult); + } }; const handleRootsChange = async () => { @@ -437,7 +457,7 @@ const App = () => { }; const sendLogLevelRequest = async (level: LoggingLevel) => { - await makeRequest( + await sendMCPRequest( { method: "logging/setLevel" as const, params: { level }, @@ -637,9 +657,10 @@ const App = () => { setTools([]); setNextToolCursor(undefined); }} - callTool={(name, params) => { + callTool={async (name, params) => { clearError("tools"); - callTool(name, params); + setToolResult(null); + await callTool(name, params); }} selectedTool={selectedTool} setSelectedTool={(tool) => { @@ -654,7 +675,7 @@ const App = () => { { - void makeRequest( + void sendMCPRequest( { method: "ping" as const, }, diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index f5b0d63..bd77639 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -3,33 +3,9 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import JsonEditor from "./JsonEditor"; -import { updateValueAtPath, JsonObject } from "@/utils/jsonPathUtils"; +import { updateValueAtPath } from "@/utils/jsonUtils"; import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils"; - -export type JsonValue = - | string - | number - | boolean - | null - | undefined - | JsonValue[] - | { [key: string]: JsonValue }; - -export type JsonSchemaType = { - type: - | "string" - | "number" - | "integer" - | "boolean" - | "array" - | "object" - | "null"; - description?: string; - required?: boolean; - default?: JsonValue; - properties?: Record; - items?: JsonSchemaType; -}; +import type { JsonValue, JsonSchemaType, JsonObject } from "@/utils/jsonUtils"; interface DynamicJsonFormProps { schema: JsonSchemaType; diff --git a/client/src/components/JsonView.tsx b/client/src/components/JsonView.tsx index b59f3cc..3fcbf8e 100644 --- a/client/src/components/JsonView.tsx +++ b/client/src/components/JsonView.tsx @@ -1,9 +1,10 @@ import { useState, memo, useMemo, useCallback, useEffect } from "react"; -import { JsonValue } from "./DynamicJsonForm"; +import type { JsonValue } from "@/utils/jsonUtils"; import clsx from "clsx"; import { Copy, CheckCheck } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useToast } from "@/hooks/use-toast"; +import { getDataType, tryParseJson } from "@/utils/jsonUtils"; interface JsonViewProps { data: unknown; @@ -11,21 +12,7 @@ interface JsonViewProps { initialExpandDepth?: number; className?: string; withCopyButton?: boolean; -} - -function tryParseJson(str: string): { success: boolean; data: JsonValue } { - const trimmed = str.trim(); - if ( - !(trimmed.startsWith("{") && trimmed.endsWith("}")) && - !(trimmed.startsWith("[") && trimmed.endsWith("]")) - ) { - return { success: false, data: str }; - } - try { - return { success: true, data: JSON.parse(str) }; - } catch { - return { success: false, data: str }; - } + isError?: boolean; } const JsonView = memo( @@ -35,6 +22,7 @@ const JsonView = memo( initialExpandDepth = 3, className, withCopyButton = true, + isError = false, }: JsonViewProps) => { const { toast } = useToast(); const [copied, setCopied] = useState(false); @@ -100,6 +88,7 @@ const JsonView = memo( name={name} depth={0} initialExpandDepth={initialExpandDepth} + isError={isError} /> @@ -114,28 +103,28 @@ interface JsonNodeProps { name?: string; depth: number; initialExpandDepth: number; + isError?: boolean; } const JsonNode = memo( - ({ data, name, depth = 0, initialExpandDepth }: JsonNodeProps) => { + ({ + data, + name, + depth = 0, + initialExpandDepth, + isError = false, + }: 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 = { + const [typeStyleMap] = useState>({ 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", + string: "text-green-600 group-hover:text-green-500", + error: "text-red-600 group-hover:text-red-500", default: "text-gray-700", - }; + }); + const dataType = getDataType(data); const renderCollapsible = (isArray: boolean) => { const items = isArray @@ -236,7 +225,14 @@ const JsonNode = memo( {name}: )} -
"{value}"
+
+              "{value}"
+            
); } @@ -250,8 +246,8 @@ const JsonNode = memo( )}
 setIsExpanded(!isExpanded)}
             title={isExpanded ? "Click to collapse" : "Click to expand"}
diff --git a/client/src/components/ListPane.tsx b/client/src/components/ListPane.tsx
index 90693dd..6f9a8cf 100644
--- a/client/src/components/ListPane.tsx
+++ b/client/src/components/ListPane.tsx
@@ -22,7 +22,7 @@ const ListPane = ({
   isButtonDisabled,
 }: ListPaneProps) => (
   
-
+

{title}

diff --git a/client/src/components/PingTab.tsx b/client/src/components/PingTab.tsx index 287356c..6546901 100644 --- a/client/src/components/PingTab.tsx +++ b/client/src/components/PingTab.tsx @@ -3,14 +3,16 @@ import { Button } from "@/components/ui/button"; const PingTab = ({ onPingClick }: { onPingClick: () => void }) => { return ( - -
- + +
+
+ +
); diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx index 6470673..c697d52 100644 --- a/client/src/components/PromptsTab.tsx +++ b/client/src/components/PromptsTab.tsx @@ -84,84 +84,88 @@ const PromptsTab = ({ }; return ( - - { - setSelectedPrompt(prompt); - setPromptArgs({}); - }} - renderItem={(prompt) => ( - <> - {prompt.name} - {prompt.description} - - )} - title="Prompts" - buttonText={nextCursor ? "List More Prompts" : "List Prompts"} - isButtonDisabled={!nextCursor && prompts.length > 0} - /> - -
-
-

- {selectedPrompt ? selectedPrompt.name : "Select a prompt"} -

-
-
- {error ? ( - - - Error - {error} - - ) : selectedPrompt ? ( -
- {selectedPrompt.description && ( -

- {selectedPrompt.description} -

- )} - {selectedPrompt.arguments?.map((arg) => ( -
- - handleInputChange(arg.name, value)} - onInputChange={(value) => - handleInputChange(arg.name, value) - } - options={completions[arg.name] || []} - /> - - {arg.description && ( -

- {arg.description} - {arg.required && ( - (Required) - )} -

- )} -
- ))} - - {promptContent && ( - - )} -
- ) : ( - - - Select a prompt from the list to view and use it - - + +
+ { + setSelectedPrompt(prompt); + setPromptArgs({}); + }} + renderItem={(prompt) => ( + <> + {prompt.name} + + {prompt.description} + + )} + title="Prompts" + buttonText={nextCursor ? "List More Prompts" : "List Prompts"} + isButtonDisabled={!nextCursor && prompts.length > 0} + /> + +
+
+

+ {selectedPrompt ? selectedPrompt.name : "Select a prompt"} +

+
+
+ {error ? ( + + + Error + {error} + + ) : selectedPrompt ? ( +
+ {selectedPrompt.description && ( +

+ {selectedPrompt.description} +

+ )} + {selectedPrompt.arguments?.map((arg) => ( +
+ + handleInputChange(arg.name, value)} + onInputChange={(value) => + handleInputChange(arg.name, value) + } + options={completions[arg.name] || []} + /> + + {arg.description && ( +

+ {arg.description} + {arg.required && ( + (Required) + )} +

+ )} +
+ ))} + + {promptContent && ( + + )} +
+ ) : ( + + + Select a prompt from the list to view and use it + + + )} +
diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index f27c91c..ac9a824 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -111,155 +111,158 @@ const ResourcesTab = ({ }; return ( - - { - setSelectedResource(resource); - readResource(resource.uri); - setSelectedTemplate(null); - }} - renderItem={(resource) => ( -
- - - {resource.name} - - -
- )} - title="Resources" - buttonText={nextCursor ? "List More Resources" : "List Resources"} - isButtonDisabled={!nextCursor && resources.length > 0} - /> - - { - setSelectedTemplate(template); - setSelectedResource(null); - setTemplateValues({}); - }} - renderItem={(template) => ( -
- - - {template.name} - - -
- )} - title="Resource Templates" - buttonText={ - nextTemplateCursor ? "List More Templates" : "List Templates" - } - isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0} - /> - -
-
-

- {selectedResource - ? selectedResource.name - : selectedTemplate - ? selectedTemplate.name - : "Select a resource or template"} -

- {selectedResource && ( -
- {resourceSubscriptionsSupported && - !resourceSubscriptions.has(selectedResource.uri) && ( - - )} - {resourceSubscriptionsSupported && - resourceSubscriptions.has(selectedResource.uri) && ( - - )} - + +
+ { + setSelectedResource(resource); + readResource(resource.uri); + setSelectedTemplate(null); + }} + renderItem={(resource) => ( +
+ + + {resource.name} + +
)} -
-
- {error ? ( - - - Error - {error} - - ) : selectedResource ? ( - - ) : selectedTemplate ? ( -
-

- {selectedTemplate.description} -

- {selectedTemplate.uriTemplate - .match(/{([^}]+)}/g) - ?.map((param) => { - const key = param.slice(1, -1); - return ( -
- - - handleTemplateValueChange(key, value) - } - onInputChange={(value) => - handleTemplateValueChange(key, value) - } - options={completions[key] || []} - /> -
- ); - })} - + title="Resources" + buttonText={nextCursor ? "List More Resources" : "List Resources"} + isButtonDisabled={!nextCursor && resources.length > 0} + /> + + { + setSelectedTemplate(template); + setSelectedResource(null); + setTemplateValues({}); + }} + renderItem={(template) => ( +
+ + + {template.name} + +
- ) : ( - - - Select a resource or template from the list to view its contents - - )} + title="Resource Templates" + buttonText={ + nextTemplateCursor ? "List More Templates" : "List Templates" + } + isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0} + /> + +
+
+

+ {selectedResource + ? selectedResource.name + : selectedTemplate + ? selectedTemplate.name + : "Select a resource or template"} +

+ {selectedResource && ( +
+ {resourceSubscriptionsSupported && + !resourceSubscriptions.has(selectedResource.uri) && ( + + )} + {resourceSubscriptionsSupported && + resourceSubscriptions.has(selectedResource.uri) && ( + + )} + +
+ )} +
+
+ {error ? ( + + + Error + {error} + + ) : selectedResource ? ( + + ) : selectedTemplate ? ( +
+

+ {selectedTemplate.description} +

+ {selectedTemplate.uriTemplate + .match(/{([^}]+)}/g) + ?.map((param) => { + const key = param.slice(1, -1); + return ( +
+ + + handleTemplateValueChange(key, value) + } + onInputChange={(value) => + handleTemplateValueChange(key, value) + } + options={completions[key] || []} + /> +
+ ); + })} + +
+ ) : ( + + + Select a resource or template from the list to view its + contents + + + )} +
diff --git a/client/src/components/RootsTab.tsx b/client/src/components/RootsTab.tsx index 33f60d5..308b88b 100644 --- a/client/src/components/RootsTab.tsx +++ b/client/src/components/RootsTab.tsx @@ -35,40 +35,42 @@ const RootsTab = ({ }; return ( - - - - Configure the root directories that the server can access - - + +
+ + + Configure the root directories that the server can access + + - {roots.map((root, index) => ( -
- updateRoot(index, "uri", e.target.value)} - className="flex-1" - /> - +
+ ))} + +
+ +
- ))} - -
- -
); diff --git a/client/src/components/SamplingTab.tsx b/client/src/components/SamplingTab.tsx index a72ea7d..d7d0212 100644 --- a/client/src/components/SamplingTab.tsx +++ b/client/src/components/SamplingTab.tsx @@ -33,33 +33,37 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => { }; return ( - - - - When the server requests LLM sampling, requests will appear here for - approval. - - -
-

Recent Requests

- {pendingRequests.map((request) => ( -
- + +
+ + + When the server requests LLM sampling, requests will appear here for + approval. + + +
+

Recent Requests

+ {pendingRequests.map((request) => ( +
+ -
- - +
+ + +
-
- ))} - {pendingRequests.length === 0 && ( -

No pending requests

- )} + ))} + {pendingRequests.length === 0 && ( +

No pending requests

+ )} +
); diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 19f8020..2ddb18d 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -92,7 +92,7 @@ const Sidebar = ({ return (
-
+

MCP Inspector v{version} @@ -103,14 +103,19 @@ const Sidebar = ({
- + setCommand(e.target.value)} @@ -132,8 +140,14 @@ const Sidebar = ({ />
- + setArgs(e.target.value)} @@ -144,8 +158,11 @@ const Sidebar = ({ ) : ( <>
- + setSseUrl(e.target.value)} @@ -157,6 +174,7 @@ const Sidebar = ({ variant="outline" onClick={() => setShowBearerToken(!showBearerToken)} className="flex items-center w-full" + aria-expanded={showBearerToken} > {showBearerToken ? ( @@ -167,8 +185,14 @@ const Sidebar = ({ {showBearerToken && (
- + setBearerToken(e.target.value)} @@ -187,6 +211,7 @@ const Sidebar = ({ onClick={() => setShowEnvVars(!showEnvVars)} className="flex items-center w-full" data-testid="env-vars-button" + aria-expanded={showEnvVars} > {showEnvVars ? ( @@ -201,6 +226,7 @@ const Sidebar = ({
{ @@ -243,6 +269,7 @@ const Sidebar = ({
setShowConfig(!showConfig)} className="flex items-center w-full" data-testid="config-button" + aria-expanded={showConfig} > {showConfig ? ( @@ -325,8 +353,11 @@ const Sidebar = ({ return (
-
{typeof configItem.value === "number" ? ( - + @@ -375,6 +407,7 @@ const Sidebar = ({ ) : ( { @@ -398,7 +431,13 @@ const Sidebar = ({
{connectionStatus === "connected" && (
- @@ -448,14 +487,19 @@ const Sidebar = ({ {loggingSupported && connectionStatus === "connected" && (
- +