Merge branch 'main' into dark-mode-fix

This commit is contained in:
Nathan Arseneau
2025-04-11 20:12:08 -04:00
35 changed files with 1257 additions and 638 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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<InspectorConfig>(() => {
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 <T extends z.ZodType>(
const sendMCPRequest = async <T extends z.ZodType>(
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<string, string> = {}) => {
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<string, unknown>) => {
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 = () => {
<ConsoleTab />
<PingTab
onPingClick={() => {
void makeRequest(
void sendMCPRequest(
{
method: "ping" as const,
},

View File

@@ -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<string, JsonSchemaType>;
items?: JsonSchemaType;
};
import type { JsonValue, JsonSchemaType, JsonObject } from "@/utils/jsonUtils";
interface DynamicJsonFormProps {
schema: JsonSchemaType;

View File

@@ -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}
/>
</div>
</div>
@@ -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<string, string> = {
const [typeStyleMap] = useState<Record<string, string>>({
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}:
</span>
)}
<pre className={typeStyleMap.string}>"{value}"</pre>
<pre
className={clsx(
typeStyleMap.string,
"break-all whitespace-pre-wrap",
)}
>
"{value}"
</pre>
</div>
);
}
@@ -250,8 +246,8 @@ const JsonNode = memo(
)}
<pre
className={clsx(
typeStyleMap.string,
"cursor-pointer group-hover:text-green-500",
isError ? typeStyleMap.error : typeStyleMap.string,
"cursor-pointer break-all whitespace-pre-wrap",
)}
onClick={() => setIsExpanded(!isExpanded)}
title={isExpanded ? "Click to collapse" : "Click to expand"}

View File

@@ -22,7 +22,7 @@ const ListPane = <T extends object>({
isButtonDisabled,
}: ListPaneProps<T>) => (
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
<h3 className="font-semibold dark:text-white">{title}</h3>
</div>
<div className="p-4">

View File

@@ -3,14 +3,16 @@ import { Button } from "@/components/ui/button";
const PingTab = ({ onPingClick }: { onPingClick: () => void }) => {
return (
<TabsContent value="ping" className="grid grid-cols-2 gap-4">
<div className="col-span-2 flex justify-center items-center">
<Button
onClick={onPingClick}
className="font-bold py-6 px-12 rounded-full"
>
Ping Server
</Button>
<TabsContent value="ping">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 flex justify-center items-center">
<Button
onClick={onPingClick}
className="font-bold py-6 px-12 rounded-full"
>
Ping Server
</Button>
</div>
</div>
</TabsContent>
);

View File

@@ -84,84 +84,88 @@ const PromptsTab = ({
};
return (
<TabsContent value="prompts" className="grid grid-cols-2 gap-4">
<ListPane
items={prompts}
listItems={listPrompts}
clearItems={clearPrompts}
setSelectedItem={(prompt) => {
setSelectedPrompt(prompt);
setPromptArgs({});
}}
renderItem={(prompt) => (
<>
<span className="flex-1">{prompt.name}</span>
<span className="text-sm text-gray-500">{prompt.description}</span>
</>
)}
title="Prompts"
buttonText={nextCursor ? "List More Prompts" : "List Prompts"}
isButtonDisabled={!nextCursor && prompts.length > 0}
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold">
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
</h3>
</div>
<div className="p-4">
{error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : selectedPrompt ? (
<div className="space-y-4">
{selectedPrompt.description && (
<p className="text-sm text-gray-600">
{selectedPrompt.description}
</p>
)}
{selectedPrompt.arguments?.map((arg) => (
<div key={arg.name}>
<Label htmlFor={arg.name}>{arg.name}</Label>
<Combobox
id={arg.name}
placeholder={`Enter ${arg.name}`}
value={promptArgs[arg.name] || ""}
onChange={(value) => handleInputChange(arg.name, value)}
onInputChange={(value) =>
handleInputChange(arg.name, value)
}
options={completions[arg.name] || []}
/>
{arg.description && (
<p className="text-xs text-gray-500 mt-1">
{arg.description}
{arg.required && (
<span className="text-xs mt-1 ml-1">(Required)</span>
)}
</p>
)}
</div>
))}
<Button onClick={handleGetPrompt} className="w-full">
Get Prompt
</Button>
{promptContent && (
<JsonView data={promptContent} withCopyButton={false} />
)}
</div>
) : (
<Alert>
<AlertDescription>
Select a prompt from the list to view and use it
</AlertDescription>
</Alert>
<TabsContent value="prompts">
<div className="grid grid-cols-2 gap-4">
<ListPane
items={prompts}
listItems={listPrompts}
clearItems={clearPrompts}
setSelectedItem={(prompt) => {
setSelectedPrompt(prompt);
setPromptArgs({});
}}
renderItem={(prompt) => (
<>
<span className="flex-1">{prompt.name}</span>
<span className="text-sm text-gray-500">
{prompt.description}
</span>
</>
)}
title="Prompts"
buttonText={nextCursor ? "List More Prompts" : "List Prompts"}
isButtonDisabled={!nextCursor && prompts.length > 0}
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
<h3 className="font-semibold">
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
</h3>
</div>
<div className="p-4">
{error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : selectedPrompt ? (
<div className="space-y-4">
{selectedPrompt.description && (
<p className="text-sm text-gray-600">
{selectedPrompt.description}
</p>
)}
{selectedPrompt.arguments?.map((arg) => (
<div key={arg.name}>
<Label htmlFor={arg.name}>{arg.name}</Label>
<Combobox
id={arg.name}
placeholder={`Enter ${arg.name}`}
value={promptArgs[arg.name] || ""}
onChange={(value) => handleInputChange(arg.name, value)}
onInputChange={(value) =>
handleInputChange(arg.name, value)
}
options={completions[arg.name] || []}
/>
{arg.description && (
<p className="text-xs text-gray-500 mt-1">
{arg.description}
{arg.required && (
<span className="text-xs mt-1 ml-1">(Required)</span>
)}
</p>
)}
</div>
))}
<Button onClick={handleGetPrompt} className="w-full">
Get Prompt
</Button>
{promptContent && (
<JsonView data={promptContent} withCopyButton={false} />
)}
</div>
) : (
<Alert>
<AlertDescription>
Select a prompt from the list to view and use it
</AlertDescription>
</Alert>
)}
</div>
</div>
</div>
</TabsContent>

View File

@@ -111,155 +111,158 @@ const ResourcesTab = ({
};
return (
<TabsContent value="resources" className="grid grid-cols-3 gap-4">
<ListPane
items={resources}
listItems={listResources}
clearItems={clearResources}
setSelectedItem={(resource) => {
setSelectedResource(resource);
readResource(resource.uri);
setSelectedTemplate(null);
}}
renderItem={(resource) => (
<div className="flex items-center w-full">
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
<span className="flex-1 truncate" title={resource.uri.toString()}>
{resource.name}
</span>
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
</div>
)}
title="Resources"
buttonText={nextCursor ? "List More Resources" : "List Resources"}
isButtonDisabled={!nextCursor && resources.length > 0}
/>
<ListPane
items={resourceTemplates}
listItems={listResourceTemplates}
clearItems={clearResourceTemplates}
setSelectedItem={(template) => {
setSelectedTemplate(template);
setSelectedResource(null);
setTemplateValues({});
}}
renderItem={(template) => (
<div className="flex items-center w-full">
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
<span className="flex-1 truncate" title={template.uriTemplate}>
{template.name}
</span>
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
</div>
)}
title="Resource Templates"
buttonText={
nextTemplateCursor ? "List More Templates" : "List Templates"
}
isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0}
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3
className="font-semibold truncate"
title={selectedResource?.name || selectedTemplate?.name}
>
{selectedResource
? selectedResource.name
: selectedTemplate
? selectedTemplate.name
: "Select a resource or template"}
</h3>
{selectedResource && (
<div className="flex row-auto gap-1 justify-end w-2/5">
{resourceSubscriptionsSupported &&
!resourceSubscriptions.has(selectedResource.uri) && (
<Button
variant="outline"
size="sm"
onClick={() => subscribeToResource(selectedResource.uri)}
>
Subscribe
</Button>
)}
{resourceSubscriptionsSupported &&
resourceSubscriptions.has(selectedResource.uri) && (
<Button
variant="outline"
size="sm"
onClick={() =>
unsubscribeFromResource(selectedResource.uri)
}
>
Unsubscribe
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => readResource(selectedResource.uri)}
>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
<TabsContent value="resources">
<div className="grid grid-cols-3 gap-4">
<ListPane
items={resources}
listItems={listResources}
clearItems={clearResources}
setSelectedItem={(resource) => {
setSelectedResource(resource);
readResource(resource.uri);
setSelectedTemplate(null);
}}
renderItem={(resource) => (
<div className="flex items-center w-full">
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
<span className="flex-1 truncate" title={resource.uri.toString()}>
{resource.name}
</span>
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
</div>
)}
</div>
<div className="p-4">
{error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : selectedResource ? (
<JsonView
data={resourceContent}
className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 text-gray-900 dark:text-gray-100"
/>
) : selectedTemplate ? (
<div className="space-y-4">
<p className="text-sm text-gray-600">
{selectedTemplate.description}
</p>
{selectedTemplate.uriTemplate
.match(/{([^}]+)}/g)
?.map((param) => {
const key = param.slice(1, -1);
return (
<div key={key}>
<Label htmlFor={key}>{key}</Label>
<Combobox
id={key}
placeholder={`Enter ${key}`}
value={templateValues[key] || ""}
onChange={(value) =>
handleTemplateValueChange(key, value)
}
onInputChange={(value) =>
handleTemplateValueChange(key, value)
}
options={completions[key] || []}
/>
</div>
);
})}
<Button
onClick={handleReadTemplateResource}
disabled={Object.keys(templateValues).length === 0}
>
Read Resource
</Button>
title="Resources"
buttonText={nextCursor ? "List More Resources" : "List Resources"}
isButtonDisabled={!nextCursor && resources.length > 0}
/>
<ListPane
items={resourceTemplates}
listItems={listResourceTemplates}
clearItems={clearResourceTemplates}
setSelectedItem={(template) => {
setSelectedTemplate(template);
setSelectedResource(null);
setTemplateValues({});
}}
renderItem={(template) => (
<div className="flex items-center w-full">
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
<span className="flex-1 truncate" title={template.uriTemplate}>
{template.name}
</span>
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
</div>
) : (
<Alert>
<AlertDescription>
Select a resource or template from the list to view its contents
</AlertDescription>
</Alert>
)}
title="Resource Templates"
buttonText={
nextTemplateCursor ? "List More Templates" : "List Templates"
}
isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0}
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-800 flex justify-between items-center">
<h3
className="font-semibold truncate"
title={selectedResource?.name || selectedTemplate?.name}
>
{selectedResource
? selectedResource.name
: selectedTemplate
? selectedTemplate.name
: "Select a resource or template"}
</h3>
{selectedResource && (
<div className="flex row-auto gap-1 justify-end w-2/5">
{resourceSubscriptionsSupported &&
!resourceSubscriptions.has(selectedResource.uri) && (
<Button
variant="outline"
size="sm"
onClick={() => subscribeToResource(selectedResource.uri)}
>
Subscribe
</Button>
)}
{resourceSubscriptionsSupported &&
resourceSubscriptions.has(selectedResource.uri) && (
<Button
variant="outline"
size="sm"
onClick={() =>
unsubscribeFromResource(selectedResource.uri)
}
>
Unsubscribe
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => readResource(selectedResource.uri)}
>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
)}
</div>
<div className="p-4">
{error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : selectedResource ? (
<JsonView
data={resourceContent}
className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 text-gray-900 dark:text-gray-100"
/>
) : selectedTemplate ? (
<div className="space-y-4">
<p className="text-sm text-gray-600">
{selectedTemplate.description}
</p>
{selectedTemplate.uriTemplate
.match(/{([^}]+)}/g)
?.map((param) => {
const key = param.slice(1, -1);
return (
<div key={key}>
<Label htmlFor={key}>{key}</Label>
<Combobox
id={key}
placeholder={`Enter ${key}`}
value={templateValues[key] || ""}
onChange={(value) =>
handleTemplateValueChange(key, value)
}
onInputChange={(value) =>
handleTemplateValueChange(key, value)
}
options={completions[key] || []}
/>
</div>
);
})}
<Button
onClick={handleReadTemplateResource}
disabled={Object.keys(templateValues).length === 0}
>
Read Resource
</Button>
</div>
) : (
<Alert>
<AlertDescription>
Select a resource or template from the list to view its
contents
</AlertDescription>
</Alert>
)}
</div>
</div>
</div>
</TabsContent>

View File

@@ -35,40 +35,42 @@ const RootsTab = ({
};
return (
<TabsContent value="roots" className="space-y-4">
<Alert>
<AlertDescription>
Configure the root directories that the server can access
</AlertDescription>
</Alert>
<TabsContent value="roots">
<div className="space-y-4">
<Alert>
<AlertDescription>
Configure the root directories that the server can access
</AlertDescription>
</Alert>
{roots.map((root, index) => (
<div key={index} className="flex gap-2 items-center">
<Input
placeholder="file:// URI"
value={root.uri}
onChange={(e) => updateRoot(index, "uri", e.target.value)}
className="flex-1"
/>
<Button
variant="destructive"
size="sm"
onClick={() => removeRoot(index)}
>
<Minus className="h-4 w-4" />
{roots.map((root, index) => (
<div key={index} className="flex gap-2 items-center">
<Input
placeholder="file:// URI"
value={root.uri}
onChange={(e) => updateRoot(index, "uri", e.target.value)}
className="flex-1"
/>
<Button
variant="destructive"
size="sm"
onClick={() => removeRoot(index)}
>
<Minus className="h-4 w-4" />
</Button>
</div>
))}
<div className="flex gap-2">
<Button variant="outline" onClick={addRoot}>
<Plus className="h-4 w-4 mr-2" />
Add Root
</Button>
<Button onClick={handleSave}>
<Save className="h-4 w-4 mr-2" />
Save Changes
</Button>
</div>
))}
<div className="flex gap-2">
<Button variant="outline" onClick={addRoot}>
<Plus className="h-4 w-4 mr-2" />
Add Root
</Button>
<Button onClick={handleSave}>
<Save className="h-4 w-4 mr-2" />
Save Changes
</Button>
</div>
</TabsContent>
);

View File

@@ -33,33 +33,37 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
};
return (
<TabsContent value="sampling" className="h-96">
<Alert>
<AlertDescription>
When the server requests LLM sampling, requests will appear here for
approval.
</AlertDescription>
</Alert>
<div className="mt-4 space-y-4">
<h3 className="text-lg font-semibold">Recent Requests</h3>
{pendingRequests.map((request) => (
<div key={request.id} className="p-4 border rounded-lg space-y-4">
<JsonView
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded"
data={JSON.stringify(request.request)}
/>
<TabsContent value="sampling">
<div className="h-96">
<Alert>
<AlertDescription>
When the server requests LLM sampling, requests will appear here for
approval.
</AlertDescription>
</Alert>
<div className="mt-4 space-y-4">
<h3 className="text-lg font-semibold">Recent Requests</h3>
{pendingRequests.map((request) => (
<div key={request.id} className="p-4 border rounded-lg space-y-4">
<JsonView
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded"
data={JSON.stringify(request.request)}
/>
<div className="flex space-x-2">
<Button onClick={() => handleApprove(request.id)}>Approve</Button>
<Button variant="outline" onClick={() => onReject(request.id)}>
Reject
</Button>
<div className="flex space-x-2">
<Button onClick={() => handleApprove(request.id)}>
Approve
</Button>
<Button variant="outline" onClick={() => onReject(request.id)}>
Reject
</Button>
</div>
</div>
</div>
))}
{pendingRequests.length === 0 && (
<p className="text-gray-500">No pending requests</p>
)}
))}
{pendingRequests.length === 0 && (
<p className="text-gray-500">No pending requests</p>
)}
</div>
</div>
</TabsContent>
);

View File

@@ -92,7 +92,7 @@ const Sidebar = ({
return (
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center">
<h1 className="ml-2 text-lg font-semibold">
MCP Inspector v{version}
@@ -103,14 +103,19 @@ const Sidebar = ({
<div className="p-4 flex-1 overflow-auto">
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Transport Type</label>
<label
className="text-sm font-medium"
htmlFor="transport-type-select"
>
Transport Type
</label>
<Select
value={transportType}
onValueChange={(value: "stdio" | "sse") =>
setTransportType(value)
}
>
<SelectTrigger>
<SelectTrigger id="transport-type-select">
<SelectValue placeholder="Select transport type" />
</SelectTrigger>
<SelectContent>
@@ -123,8 +128,11 @@ const Sidebar = ({
{transportType === "stdio" ? (
<>
<div className="space-y-2">
<label className="text-sm font-medium">Command</label>
<label className="text-sm font-medium" htmlFor="command-input">
Command
</label>
<Input
id="command-input"
placeholder="Command"
value={command}
onChange={(e) => setCommand(e.target.value)}
@@ -132,8 +140,14 @@ const Sidebar = ({
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Arguments</label>
<label
className="text-sm font-medium"
htmlFor="arguments-input"
>
Arguments
</label>
<Input
id="arguments-input"
placeholder="Arguments (space-separated)"
value={args}
onChange={(e) => setArgs(e.target.value)}
@@ -144,8 +158,11 @@ const Sidebar = ({
) : (
<>
<div className="space-y-2">
<label className="text-sm font-medium">URL</label>
<label className="text-sm font-medium" htmlFor="sse-url-input">
URL
</label>
<Input
id="sse-url-input"
placeholder="URL"
value={sseUrl}
onChange={(e) => 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 ? (
<ChevronDown className="w-4 h-4 mr-2" />
@@ -167,8 +185,14 @@ const Sidebar = ({
</Button>
{showBearerToken && (
<div className="space-y-2">
<label className="text-sm font-medium">Bearer Token</label>
<label
className="text-sm font-medium"
htmlFor="bearer-token-input"
>
Bearer Token
</label>
<Input
id="bearer-token-input"
placeholder="Bearer Token"
value={bearerToken}
onChange={(e) => 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 ? (
<ChevronDown className="w-4 h-4 mr-2" />
@@ -201,6 +226,7 @@ const Sidebar = ({
<div key={idx} className="space-y-2 pb-4">
<div className="flex gap-2">
<Input
aria-label={`Environment variable key ${idx + 1}`}
placeholder="Key"
value={key}
onChange={(e) => {
@@ -243,6 +269,7 @@ const Sidebar = ({
</div>
<div className="flex gap-2">
<Input
aria-label={`Environment variable value ${idx + 1}`}
type={shownEnvVars.has(key) ? "text" : "password"}
placeholder="Value"
value={value}
@@ -309,6 +336,7 @@ const Sidebar = ({
onClick={() => setShowConfig(!showConfig)}
className="flex items-center w-full"
data-testid="config-button"
aria-expanded={showConfig}
>
{showConfig ? (
<ChevronDown className="w-4 h-4 mr-2" />
@@ -325,8 +353,11 @@ const Sidebar = ({
return (
<div key={key} className="space-y-2">
<div className="flex items-center gap-1">
<label className="text-sm font-medium text-green-600">
{configKey}
<label
className="text-sm font-medium text-green-600 break-all"
htmlFor={`${configKey}-input`}
>
{configItem.label}
</label>
<Tooltip>
<TooltipTrigger asChild>
@@ -339,6 +370,7 @@ const Sidebar = ({
</div>
{typeof configItem.value === "number" ? (
<Input
id={`${configKey}-input`}
type="number"
data-testid={`${configKey}-input`}
value={configItem.value}
@@ -365,7 +397,7 @@ const Sidebar = ({
setConfig(newConfig);
}}
>
<SelectTrigger>
<SelectTrigger id={`${configKey}-input`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -375,6 +407,7 @@ const Sidebar = ({
</Select>
) : (
<Input
id={`${configKey}-input`}
data-testid={`${configKey}-input`}
value={configItem.value}
onChange={(e) => {
@@ -398,7 +431,13 @@ const Sidebar = ({
<div className="space-y-2">
{connectionStatus === "connected" && (
<div className="grid grid-cols-2 gap-4">
<Button data-testid="connect-button" onClick={onConnect}>
<Button
data-testid="connect-button"
onClick={() => {
onDisconnect();
onConnect();
}}
>
<RotateCcw className="w-4 h-4 mr-2" />
{transportType === "stdio" ? "Restart" : "Reconnect"}
</Button>
@@ -448,14 +487,19 @@ const Sidebar = ({
{loggingSupported && connectionStatus === "connected" && (
<div className="space-y-2">
<label className="text-sm font-medium">Logging Level</label>
<label
className="text-sm font-medium"
htmlFor="logging-level-select"
>
Logging Level
</label>
<Select
value={logLevel}
onValueChange={(value: LoggingLevel) =>
sendLogLevelRequest(value)
}
>
<SelectTrigger>
<SelectTrigger id="logging-level-select">
<SelectValue placeholder="Select logging level" />
</SelectTrigger>
<SelectContent>

View File

@@ -5,7 +5,8 @@ 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 DynamicJsonForm from "./DynamicJsonForm";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import {
CallToolResultSchema,
@@ -13,7 +14,7 @@ import {
ListToolsResult,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { Send } from "lucide-react";
import { Loader2, Send } from "lucide-react";
import { useEffect, useState } from "react";
import ListPane from "./ListPane";
import JsonView from "./JsonView";
@@ -31,7 +32,7 @@ const ToolsTab = ({
tools: Tool[];
listTools: () => void;
clearTools: () => void;
callTool: (name: string, params: Record<string, unknown>) => void;
callTool: (name: string, params: Record<string, unknown>) => Promise<void>;
selectedTool: Tool | null;
setSelectedTool: (tool: Tool | null) => void;
toolResult: CompatibilityCallToolResult | null;
@@ -39,6 +40,8 @@ const ToolsTab = ({
error: string | null;
}) => {
const [params, setParams] = useState<Record<string, unknown>>({});
const [isToolRunning, setIsToolRunning] = useState(false);
useEffect(() => {
setParams({});
}, [selectedTool]);
@@ -66,11 +69,18 @@ const ToolsTab = ({
return (
<>
<h4 className="font-semibold mb-2">
Tool Result: {isError ? "Error" : "Success"}
Tool Result:{" "}
{isError ? (
<span className="text-red-600 font-semibold">Error</span>
) : (
<span className="text-green-600 font-semibold">Success</span>
)}
</h4>
{structuredResult.content.map((item, index) => (
<div key={index} className="mb-2">
{item.type === "text" && <JsonView data={item.text} />}
{item.type === "text" && (
<JsonView data={item.text} isError={isError} />
)}
{item.type === "image" && (
<img
src={`data:${item.mimeType};base64,${item.data}`}
@@ -106,147 +116,168 @@ const ToolsTab = ({
};
return (
<TabsContent value="tools" className="grid grid-cols-2 gap-4">
<ListPane
items={tools}
listItems={listTools}
clearItems={() => {
clearTools();
setSelectedTool(null);
}}
setSelectedItem={setSelectedTool}
renderItem={(tool) => (
<>
<span className="flex-1">{tool.name}</span>
<span className="text-sm text-gray-500 text-right">
{tool.description}
</span>
</>
)}
title="Tools"
buttonText={nextCursor ? "List More Tools" : "List Tools"}
isButtonDisabled={!nextCursor && tools.length > 0}
/>
<TabsContent value="tools">
<div className="grid grid-cols-2 gap-4">
<ListPane
items={tools}
listItems={listTools}
clearItems={() => {
clearTools();
setSelectedTool(null);
}}
setSelectedItem={setSelectedTool}
renderItem={(tool) => (
<>
<span className="flex-1">{tool.name}</span>
<span className="text-sm text-gray-500 text-right">
{tool.description}
</span>
</>
)}
title="Tools"
buttonText={nextCursor ? "List More Tools" : "List Tools"}
isButtonDisabled={!nextCursor && tools.length > 0}
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold">
{selectedTool ? selectedTool.name : "Select a tool"}
</h3>
</div>
<div className="p-4">
{selectedTool ? (
<div className="space-y-4">
<p className="text-sm text-gray-600">
{selectedTool.description}
</p>
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => {
const prop = value as JsonSchemaType;
return (
<div key={key}>
<Label
htmlFor={key}
className="block text-sm font-medium text-gray-700"
>
{key}
</Label>
{prop.type === "boolean" ? (
<div className="flex items-center space-x-2 mt-2">
<Checkbox
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
<h3 className="font-semibold">
{selectedTool ? selectedTool.name : "Select a tool"}
</h3>
</div>
<div className="p-4">
{selectedTool ? (
<div className="space-y-4">
<p className="text-sm text-gray-600">
{selectedTool.description}
</p>
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => {
const prop = value as JsonSchemaType;
return (
<div key={key}>
<Label
htmlFor={key}
className="block text-sm font-medium text-gray-700"
>
{key}
</Label>
{prop.type === "boolean" ? (
<div className="flex items-center space-x-2 mt-2">
<Checkbox
id={key}
name={key}
checked={!!params[key]}
onCheckedChange={(checked: boolean) =>
setParams({
...params,
[key]: checked,
})
}
/>
<label
htmlFor={key}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{prop.description || "Toggle this option"}
</label>
</div>
) : prop.type === "string" ? (
<Textarea
id={key}
name={key}
checked={!!params[key]}
onCheckedChange={(checked: boolean) =>
placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) =>
setParams({
...params,
[key]: checked,
[key]: e.target.value,
})
}
className="mt-1"
/>
<label
htmlFor={key}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{prop.description || "Toggle this option"}
</label>
</div>
) : prop.type === "string" ? (
<Textarea
id={key}
name={key}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) =>
setParams({
...params,
[key]: e.target.value,
})
}
className="mt-1"
/>
) : prop.type === "object" || prop.type === "array" ? (
<div className="mt-1">
<DynamicJsonForm
schema={{
type: prop.type,
properties: prop.properties,
description: prop.description,
items: prop.items,
}}
value={
(params[key] as JsonValue) ??
generateDefaultValue(prop)
) : prop.type === "object" || prop.type === "array" ? (
<div className="mt-1">
<DynamicJsonForm
schema={{
type: prop.type,
properties: prop.properties,
description: prop.description,
items: prop.items,
}}
value={
(params[key] as JsonValue) ??
generateDefaultValue(prop)
}
onChange={(newValue: JsonValue) => {
setParams({
...params,
[key]: newValue,
});
}}
/>
</div>
) : (
<Input
type={
prop.type === "number" || prop.type === "integer"
? "number"
: "text"
}
onChange={(newValue: JsonValue) => {
id={key}
name={key}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) =>
setParams({
...params,
[key]: newValue,
});
}}
[key]:
prop.type === "number" ||
prop.type === "integer"
? Number(e.target.value)
: e.target.value,
})
}
className="mt-1"
/>
</div>
) : (
<Input
type={
prop.type === "number" || prop.type === "integer"
? "number"
: "text"
}
id={key}
name={key}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) =>
setParams({
...params,
[key]:
prop.type === "number" ||
prop.type === "integer"
? Number(e.target.value)
: e.target.value,
})
}
className="mt-1"
/>
)}
</div>
);
},
)}
<Button onClick={() => callTool(selectedTool.name, params)}>
<Send className="w-4 h-4 mr-2" />
Run Tool
</Button>
{toolResult && renderToolResult()}
</div>
) : (
<Alert>
<AlertDescription>
Select a tool from the list to view its details and run it
</AlertDescription>
</Alert>
)}
)}
</div>
);
},
)}
<Button
onClick={async () => {
try {
setIsToolRunning(true);
await callTool(selectedTool.name, params);
} finally {
setIsToolRunning(false);
}
}}
disabled={isToolRunning}
>
{isToolRunning ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Running...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Run Tool
</>
)}
</Button>
{toolResult && renderToolResult()}
</div>
) : (
<Alert>
<AlertDescription>
Select a tool from the list to view its details and run it
</AlertDescription>
</Alert>
)}
</div>
</div>
</div>
</TabsContent>

View File

@@ -1,7 +1,7 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils";
describe("DynamicJsonForm String Fields", () => {
const renderForm = (props = {}) => {

View File

@@ -343,6 +343,7 @@ describe("Sidebar Environment Variables", () => {
expect(setConfig).toHaveBeenCalledWith(
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 5000,
},
@@ -350,6 +351,56 @@ describe("Sidebar Environment Variables", () => {
);
});
it("should update MCP server proxy address", () => {
const setConfig = jest.fn();
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
openConfigSection();
const proxyAddressInput = screen.getByTestId(
"MCP_PROXY_FULL_ADDRESS-input",
);
fireEvent.change(proxyAddressInput, {
target: { value: "http://localhost:8080" },
});
expect(setConfig).toHaveBeenCalledWith(
expect.objectContaining({
MCP_PROXY_FULL_ADDRESS: {
label: "Inspector Proxy Address",
description:
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
value: "http://localhost:8080",
},
}),
);
});
it("should update max total timeout", () => {
const setConfig = jest.fn();
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
openConfigSection();
const maxTotalTimeoutInput = screen.getByTestId(
"MCP_REQUEST_MAX_TOTAL_TIMEOUT-input",
);
fireEvent.change(maxTotalTimeoutInput, {
target: { value: "10000" },
});
expect(setConfig).toHaveBeenCalledWith(
expect.objectContaining({
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
label: "Maximum Total Timeout",
description:
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
value: 10000,
},
}),
);
});
it("should handle invalid timeout values entered by user", () => {
const setConfig = jest.fn();
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
@@ -364,6 +415,7 @@ describe("Sidebar Environment Variables", () => {
expect(setConfig).toHaveBeenCalledWith(
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 0,
},
@@ -409,6 +461,7 @@ describe("Sidebar Environment Variables", () => {
expect(setConfig).toHaveBeenLastCalledWith(
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 3000,
},

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import "@testing-library/jest-dom";
import ToolsTab from "../ToolsTab";
@@ -43,7 +43,7 @@ describe("ToolsTab", () => {
tools: mockTools,
listTools: jest.fn(),
clearTools: jest.fn(),
callTool: jest.fn(),
callTool: jest.fn(async () => {}),
selectedTool: null,
setSelectedTool: jest.fn(),
toolResult: null,
@@ -59,14 +59,16 @@ describe("ToolsTab", () => {
);
};
it("should reset input values when switching tools", () => {
it("should reset input values when switching tools", async () => {
const { rerender } = renderToolsTab({
selectedTool: mockTools[0],
});
// Enter a value in the first tool's input
const input = screen.getByRole("spinbutton") as HTMLInputElement;
fireEvent.change(input, { target: { value: "42" } });
await act(async () => {
fireEvent.change(input, { target: { value: "42" } });
});
expect(input.value).toBe("42");
// Switch to second tool
@@ -80,7 +82,8 @@ describe("ToolsTab", () => {
const newInput = screen.getByRole("spinbutton") as HTMLInputElement;
expect(newInput.value).toBe("");
});
it("should handle integer type inputs", () => {
it("should handle integer type inputs", async () => {
renderToolsTab({
selectedTool: mockTools[1], // Use the tool with integer type
});
@@ -93,10 +96,49 @@ describe("ToolsTab", () => {
expect(input.value).toBe("42");
const submitButton = screen.getByRole("button", { name: /run tool/i });
fireEvent.click(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
count: 42,
});
});
it("should disable button and change text while tool is running", async () => {
// Create a promise that we can resolve later
let resolvePromise: ((value: unknown) => void) | undefined;
const mockPromise = new Promise((resolve) => {
resolvePromise = resolve;
});
// Mock callTool to return our promise
const mockCallTool = jest.fn().mockReturnValue(mockPromise);
renderToolsTab({
selectedTool: mockTools[0],
callTool: mockCallTool,
});
const submitButton = screen.getByRole("button", { name: /run tool/i });
expect(submitButton.getAttribute("disabled")).toBeNull();
// Click the button and verify immediate state changes
await act(async () => {
fireEvent.click(submitButton);
});
// Verify button is disabled and text changed
expect(submitButton.getAttribute("disabled")).not.toBeNull();
expect(submitButton.textContent).toBe("Running...");
// Resolve the promise to simulate tool completion
await act(async () => {
if (resolvePromise) {
await resolvePromise({});
}
});
expect(submitButton.getAttribute("disabled")).toBeNull();
});
});

View File

@@ -54,4 +54,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
);
Button.displayName = "Button";
export { Button, buttonVariants };
export { Button };

View File

@@ -2,8 +2,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {

View File

@@ -42,7 +42,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}

View File

@@ -2,8 +2,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {

View File

@@ -15,13 +15,6 @@ type ToasterToast = ToastProps & {
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
@@ -29,23 +22,28 @@ function genId() {
return count.toString();
}
type ActionType = typeof actionTypes;
const enum ActionType {
ADD_TOAST = "ADD_TOAST",
UPDATE_TOAST = "UPDATE_TOAST",
DISMISS_TOAST = "DISMISS_TOAST",
REMOVE_TOAST = "REMOVE_TOAST",
}
type Action =
| {
type: ActionType["ADD_TOAST"];
type: ActionType.ADD_TOAST;
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
type: ActionType.UPDATE_TOAST;
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
type: ActionType.DISMISS_TOAST;
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
type: ActionType.REMOVE_TOAST;
toastId?: ToasterToast["id"];
};
@@ -63,7 +61,7 @@ const addToRemoveQueue = (toastId: string) => {
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
type: ActionType.REMOVE_TOAST,
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
@@ -73,13 +71,13 @@ const addToRemoveQueue = (toastId: string) => {
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
case ActionType.ADD_TOAST:
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
case ActionType.UPDATE_TOAST:
return {
...state,
toasts: state.toasts.map((t) =>
@@ -87,7 +85,7 @@ export const reducer = (state: State, action: Action): State => {
),
};
case "DISMISS_TOAST": {
case ActionType.DISMISS_TOAST: {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
@@ -112,7 +110,7 @@ export const reducer = (state: State, action: Action): State => {
),
};
}
case "REMOVE_TOAST":
case ActionType.REMOVE_TOAST:
if (action.toastId === undefined) {
return {
...state,
@@ -144,13 +142,14 @@ function toast({ ...props }: Toast) {
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
type: ActionType.UPDATE_TOAST,
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
const dismiss = () =>
dispatch({ type: ActionType.DISMISS_TOAST, toastId: id });
dispatch({
type: "ADD_TOAST",
type: ActionType.ADD_TOAST,
toast: {
...props,
id,
@@ -184,7 +183,8 @@ function useToast() {
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
dismiss: (toastId?: string) =>
dispatch({ type: ActionType.DISMISS_TOAST, toastId }),
};
}

View File

@@ -1,4 +1,5 @@
export type ConfigItem = {
label: string;
description: string;
value: string | number | boolean;
};
@@ -15,5 +16,21 @@ export type InspectorConfig = {
* Maximum time in milliseconds to wait for a response from the MCP server before timing out.
*/
MCP_SERVER_REQUEST_TIMEOUT: ConfigItem;
/**
* Whether to reset the timeout on progress notifications. Useful for long-running operations that send periodic progress updates.
* Refer: https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress/#progress-flow
*/
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: ConfigItem;
/**
* Maximum total time in milliseconds to wait for a response from the MCP server before timing out. Used in conjunction with MCP_SERVER_REQUEST_TIMEOUT_RESET_ON_PROGRESS.
* Refer: https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress/#progress-flow
*/
MCP_REQUEST_MAX_TOTAL_TIMEOUT: ConfigItem;
/**
* The full address of the MCP Proxy Server, in case it is running on a non-default address. Example: http://10.1.1.22:5577
*/
MCP_PROXY_FULL_ADDRESS: ConfigItem;
};

View File

@@ -22,10 +22,23 @@ export const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
**/
export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 10000,
},
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: {
label: "Reset Timeout on Progress",
description: "Reset timeout on progress notifications",
value: true,
},
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
label: "Maximum Total Timeout",
description:
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
value: 60000,
},
MCP_PROXY_FULL_ADDRESS: {
label: "Inspector Proxy Address",
description:
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
value: "",

View File

@@ -0,0 +1,164 @@
import { renderHook, act } from "@testing-library/react";
import { useConnection } from "../useConnection";
import { z } from "zod";
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
import { DEFAULT_INSPECTOR_CONFIG } from "../../constants";
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({
json: () => Promise.resolve({ status: "ok" }),
});
// Mock the SDK dependencies
const mockRequest = jest.fn().mockResolvedValue({ test: "response" });
const mockClient = {
request: mockRequest,
notification: jest.fn(),
connect: jest.fn().mockResolvedValue(undefined),
close: jest.fn(),
getServerCapabilities: jest.fn(),
setNotificationHandler: jest.fn(),
setRequestHandler: jest.fn(),
};
jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: jest.fn().mockImplementation(() => mockClient),
}));
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
SSEClientTransport: jest.fn(),
SseError: jest.fn(),
}));
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
auth: jest.fn().mockResolvedValue("AUTHORIZED"),
}));
// Mock the toast hook
jest.mock("@/hooks/use-toast", () => ({
useToast: () => ({
toast: jest.fn(),
}),
}));
// Mock the auth provider
jest.mock("../../auth", () => ({
authProvider: {
tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }),
},
}));
describe("useConnection", () => {
const defaultProps = {
transportType: "sse" as const,
command: "",
args: "",
sseUrl: "http://localhost:8080",
env: {},
config: DEFAULT_INSPECTOR_CONFIG,
};
describe("Request Configuration", () => {
beforeEach(() => {
jest.clearAllMocks();
});
test("uses the default config values in makeRequest", async () => {
const { result } = renderHook(() => useConnection(defaultProps));
// Connect the client
await act(async () => {
await result.current.connect();
});
// Wait for state update
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
const mockRequest: ClientRequest = {
method: "ping",
params: {},
};
const mockSchema = z.object({
test: z.string(),
});
await act(async () => {
await result.current.makeRequest(mockRequest, mockSchema);
});
expect(mockClient.request).toHaveBeenCalledWith(
mockRequest,
mockSchema,
expect.objectContaining({
timeout: DEFAULT_INSPECTOR_CONFIG.MCP_SERVER_REQUEST_TIMEOUT.value,
maxTotalTimeout:
DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value,
resetTimeoutOnProgress:
DEFAULT_INSPECTOR_CONFIG.MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS
.value,
}),
);
});
test("overrides the default config values when passed in options in makeRequest", async () => {
const { result } = renderHook(() => useConnection(defaultProps));
// Connect the client
await act(async () => {
await result.current.connect();
});
// Wait for state update
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
const mockRequest: ClientRequest = {
method: "ping",
params: {},
};
const mockSchema = z.object({
test: z.string(),
});
await act(async () => {
await result.current.makeRequest(mockRequest, mockSchema, {
timeout: 1000,
maxTotalTimeout: 2000,
resetTimeoutOnProgress: false,
});
});
expect(mockClient.request).toHaveBeenCalledWith(
mockRequest,
mockSchema,
expect.objectContaining({
timeout: 1000,
maxTotalTimeout: 2000,
resetTimeoutOnProgress: false,
}),
);
});
});
test("throws error when mcpClient is not connected", async () => {
const { result } = renderHook(() => useConnection(defaultProps));
const mockRequest: ClientRequest = {
method: "ping",
params: {},
};
const mockSchema = z.object({
test: z.string(),
});
await expect(
result.current.makeRequest(mockRequest, mockSchema),
).rejects.toThrow("MCP client not connected");
});
});

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import {
ResourceReference,
PromptReference,
@@ -15,9 +15,11 @@ function debounce<T extends (...args: any[]) => PromiseLike<void>>(
wait: number,
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>;
return function (...args: Parameters<T>) {
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
timeout = setTimeout(() => {
void func(...args);
}, wait);
};
}
@@ -58,8 +60,8 @@ export function useCompletionState(
});
}, [cleanup]);
const requestCompletions = useCallback(
debounce(
const requestCompletions = useMemo(() => {
return debounce(
async (
ref: ResourceReference | PromptReference,
argName: string,
@@ -94,7 +96,7 @@ export function useCompletionState(
loading: { ...prev.loading, [argName]: false },
}));
}
} catch (err) {
} catch {
if (!abortController.signal.aborted) {
setState((prev) => ({
...prev,
@@ -108,9 +110,8 @@ export function useCompletionState(
}
},
debounceMs,
),
[handleCompletion, completionsSupported, cleanup, debounceMs],
);
);
}, [handleCompletion, completionsSupported, cleanup, debounceMs]);
// Clear completions when support status changes
useEffect(() => {

View File

@@ -8,7 +8,6 @@ import {
ClientRequest,
CreateMessageRequestSchema,
ListRootsRequestSchema,
ProgressNotificationSchema,
ResourceUpdatedNotificationSchema,
LoggingMessageNotificationSchema,
Request,
@@ -23,7 +22,9 @@ import {
ResourceListChangedNotificationSchema,
ToolListChangedNotificationSchema,
PromptListChangedNotificationSchema,
Progress,
} from "@modelcontextprotocol/sdk/types.js";
import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { z } from "zod";
@@ -32,6 +33,13 @@ import { Notification, StdErrNotificationSchema } from "../notificationTypes";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { authProvider } from "../auth";
import packageJson from "../../../package.json";
import {
getMCPProxyAddress,
getMCPServerRequestMaxTotalTimeout,
resetRequestTimeoutOnProgress,
} from "@/utils/configUtils";
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
import { InspectorConfig } from "../configurationTypes";
interface UseConnectionOptions {
transportType: "stdio" | "sse";
@@ -39,9 +47,8 @@ interface UseConnectionOptions {
args: string;
sseUrl: string;
env: Record<string, string>;
proxyServerUrl: string;
bearerToken?: string;
requestTimeout?: number;
config: InspectorConfig;
onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -50,21 +57,14 @@ interface UseConnectionOptions {
getRoots?: () => any[];
}
interface RequestOptions {
signal?: AbortSignal;
timeout?: number;
suppressToast?: boolean;
}
export function useConnection({
transportType,
command,
args,
sseUrl,
env,
proxyServerUrl,
bearerToken,
requestTimeout,
config,
onNotification,
onStdErrNotification,
onPendingRequest,
@@ -94,31 +94,50 @@ export function useConnection({
const makeRequest = async <T extends z.ZodType>(
request: ClientRequest,
schema: T,
options?: RequestOptions,
options?: RequestOptions & { suppressToast?: boolean },
): Promise<z.output<T>> => {
if (!mcpClient) {
throw new Error("MCP client not connected");
}
try {
const abortController = new AbortController();
const timeoutId = setTimeout(() => {
abortController.abort("Request timed out");
}, options?.timeout ?? requestTimeout);
// prepare MCP Client request options
const mcpRequestOptions: RequestOptions = {
signal: options?.signal ?? abortController.signal,
resetTimeoutOnProgress:
options?.resetTimeoutOnProgress ??
resetRequestTimeoutOnProgress(config),
timeout: options?.timeout ?? getMCPServerRequestTimeout(config),
maxTotalTimeout:
options?.maxTotalTimeout ??
getMCPServerRequestMaxTotalTimeout(config),
};
// If progress notifications are enabled, add an onprogress hook to the MCP Client request options
// This is required by SDK to reset the timeout on progress notifications
if (mcpRequestOptions.resetTimeoutOnProgress) {
mcpRequestOptions.onprogress = (params: Progress) => {
// Add progress notification to `Server Notification` window in the UI
if (onNotification) {
onNotification({
method: "notification/progress",
params,
});
}
};
}
let response;
try {
response = await mcpClient.request(request, schema, {
signal: options?.signal ?? abortController.signal,
});
response = await mcpClient.request(request, schema, mcpRequestOptions);
pushHistory(request, response);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
pushHistory(request, { error: errorMessage });
throw error;
} finally {
clearTimeout(timeoutId);
}
return response;
@@ -211,7 +230,7 @@ export function useConnection({
const checkProxyHealth = async () => {
try {
const proxyHealthUrl = new URL(`${proxyServerUrl}/health`);
const proxyHealthUrl = new URL(`${getMCPProxyAddress(config)}/health`);
const proxyHealthResponse = await fetch(proxyHealthUrl);
const proxyHealth = await proxyHealthResponse.json();
if (proxyHealth?.status !== "ok") {
@@ -256,7 +275,7 @@ export function useConnection({
setConnectionStatus("error-connecting-to-proxy");
return;
}
const mcpProxyServerUrl = new URL(`${proxyServerUrl}/sse`);
const mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
mcpProxyServerUrl.searchParams.append("transportType", transportType);
if (transportType === "stdio") {
mcpProxyServerUrl.searchParams.append("command", command);
@@ -289,7 +308,6 @@ export function useConnection({
if (onNotification) {
[
CancelledNotificationSchema,
ProgressNotificationSchema,
LoggingMessageNotificationSchema,
ResourceUpdatedNotificationSchema,
ResourceListChangedNotificationSchema,

View File

@@ -43,7 +43,10 @@ const useTheme = (): [Theme, (mode: Theme) => void] => {
document.documentElement.classList.toggle("dark", newTheme === "dark");
}
}, []);
return useMemo(() => [theme, setThemeWithSideEffect], [theme]);
return useMemo(
() => [theme, setThemeWithSideEffect],
[theme, setThemeWithSideEffect],
);
};
export default useTheme;

View File

@@ -1,5 +1,146 @@
import { updateValueAtPath, getValueAtPath } from "../jsonPathUtils";
import { JsonValue } from "../../components/DynamicJsonForm";
import {
getDataType,
tryParseJson,
updateValueAtPath,
getValueAtPath,
} from "../jsonUtils";
import type { JsonValue } from "../jsonUtils";
describe("getDataType", () => {
test("should return 'string' for string values", () => {
expect(getDataType("hello")).toBe("string");
expect(getDataType("")).toBe("string");
});
test("should return 'number' for number values", () => {
expect(getDataType(123)).toBe("number");
expect(getDataType(0)).toBe("number");
expect(getDataType(-10)).toBe("number");
expect(getDataType(1.5)).toBe("number");
expect(getDataType(NaN)).toBe("number");
expect(getDataType(Infinity)).toBe("number");
});
test("should return 'boolean' for boolean values", () => {
expect(getDataType(true)).toBe("boolean");
expect(getDataType(false)).toBe("boolean");
});
test("should return 'undefined' for undefined value", () => {
expect(getDataType(undefined)).toBe("undefined");
});
test("should return 'object' for object values", () => {
expect(getDataType({})).toBe("object");
expect(getDataType({ key: "value" })).toBe("object");
});
test("should return 'array' for array values", () => {
expect(getDataType([])).toBe("array");
expect(getDataType([1, 2, 3])).toBe("array");
expect(getDataType(["a", "b", "c"])).toBe("array");
expect(getDataType([{}, { nested: true }])).toBe("array");
});
test("should return 'null' for null value", () => {
expect(getDataType(null)).toBe("null");
});
});
describe("tryParseJson", () => {
test("should correctly parse valid JSON object", () => {
const jsonString = '{"name":"test","value":123}';
const result = tryParseJson(jsonString);
expect(result.success).toBe(true);
expect(result.data).toEqual({ name: "test", value: 123 });
});
test("should correctly parse valid JSON array", () => {
const jsonString = '[1,2,3,"test"]';
const result = tryParseJson(jsonString);
expect(result.success).toBe(true);
expect(result.data).toEqual([1, 2, 3, "test"]);
});
test("should correctly parse JSON with whitespace", () => {
const jsonString = ' { "name" : "test" } ';
const result = tryParseJson(jsonString);
expect(result.success).toBe(true);
expect(result.data).toEqual({ name: "test" });
});
test("should correctly parse nested JSON structures", () => {
const jsonString =
'{"user":{"name":"test","details":{"age":30}},"items":[1,2,3]}';
const result = tryParseJson(jsonString);
expect(result.success).toBe(true);
expect(result.data).toEqual({
user: {
name: "test",
details: {
age: 30,
},
},
items: [1, 2, 3],
});
});
test("should correctly parse empty objects and arrays", () => {
expect(tryParseJson("{}").success).toBe(true);
expect(tryParseJson("{}").data).toEqual({});
expect(tryParseJson("[]").success).toBe(true);
expect(tryParseJson("[]").data).toEqual([]);
});
test("should return failure for non-JSON strings", () => {
const nonJsonString = "this is not json";
const result = tryParseJson(nonJsonString);
expect(result.success).toBe(false);
expect(result.data).toBe(nonJsonString);
});
test("should return failure for malformed JSON", () => {
const malformedJson = '{"name":"test",}';
const result = tryParseJson(malformedJson);
expect(result.success).toBe(false);
expect(result.data).toBe(malformedJson);
});
test("should return failure for strings with correct delimiters but invalid JSON", () => {
const invalidJson = "{name:test}";
const result = tryParseJson(invalidJson);
expect(result.success).toBe(false);
expect(result.data).toBe(invalidJson);
});
test("should handle edge cases", () => {
expect(tryParseJson("").success).toBe(false);
expect(tryParseJson("").data).toBe("");
expect(tryParseJson(" ").success).toBe(false);
expect(tryParseJson(" ").data).toBe(" ");
expect(tryParseJson("null").success).toBe(false);
expect(tryParseJson("null").data).toBe("null");
expect(tryParseJson('"string"').success).toBe(false);
expect(tryParseJson('"string"').data).toBe('"string"');
expect(tryParseJson("123").success).toBe(false);
expect(tryParseJson("123").data).toBe("123");
expect(tryParseJson("true").success).toBe(false);
expect(tryParseJson("true").data).toBe("true");
});
});
describe("updateValueAtPath", () => {
// Basic functionality tests
@@ -8,17 +149,17 @@ describe("updateValueAtPath", () => {
});
test("initializes an empty object when input is null/undefined and path starts with a string", () => {
expect(updateValueAtPath(null as any, ["foo"], "bar")).toEqual({
expect(updateValueAtPath(null, ["foo"], "bar")).toEqual({
foo: "bar",
});
expect(updateValueAtPath(undefined as any, ["foo"], "bar")).toEqual({
expect(updateValueAtPath(undefined, ["foo"], "bar")).toEqual({
foo: "bar",
});
});
test("initializes an empty array when input is null/undefined and path starts with a number", () => {
expect(updateValueAtPath(null as any, ["0"], "bar")).toEqual(["bar"]);
expect(updateValueAtPath(undefined as any, ["0"], "bar")).toEqual(["bar"]);
expect(updateValueAtPath(null, ["0"], "bar")).toEqual(["bar"]);
expect(updateValueAtPath(undefined, ["0"], "bar")).toEqual(["bar"]);
});
// Object update tests
@@ -152,10 +293,8 @@ describe("getValueAtPath", () => {
});
test("returns default value when input is null/undefined", () => {
expect(getValueAtPath(null as any, ["foo"], "default")).toBe("default");
expect(getValueAtPath(undefined as any, ["foo"], "default")).toBe(
"default",
);
expect(getValueAtPath(null, ["foo"], "default")).toBe("default");
expect(getValueAtPath(undefined, ["foo"], "default")).toBe("default");
});
test("handles array indices correctly", () => {

View File

@@ -1,5 +1,5 @@
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
import { JsonSchemaType } from "../../components/DynamicJsonForm";
import type { JsonSchemaType } from "../jsonUtils";
describe("generateDefaultValue", () => {
test("generates default string", () => {

View File

@@ -12,3 +12,15 @@ export const getMCPProxyAddress = (config: InspectorConfig): string => {
export const getMCPServerRequestTimeout = (config: InspectorConfig): number => {
return config.MCP_SERVER_REQUEST_TIMEOUT.value as number;
};
export const resetRequestTimeoutOnProgress = (
config: InspectorConfig,
): boolean => {
return config.MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS.value as boolean;
};
export const getMCPServerRequestMaxTotalTimeout = (
config: InspectorConfig,
): number => {
return config.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value as number;
};

View File

@@ -1,7 +1,66 @@
import { JsonValue } from "../components/DynamicJsonForm";
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<string, JsonSchemaType>;
items?: JsonSchemaType;
};
export type JsonObject = { [key: string]: JsonValue };
export type DataType =
| "string"
| "number"
| "bigint"
| "boolean"
| "symbol"
| "undefined"
| "object"
| "function"
| "array"
| "null";
export function getDataType(value: JsonValue): DataType {
if (Array.isArray(value)) return "array";
if (value === null) return "null";
return typeof value;
}
export 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 };
}
}
/**
* Updates a value at a specific path in a nested JSON structure
* @param obj The original JSON value

View File

@@ -1,5 +1,4 @@
import { JsonValue, JsonSchemaType } from "../components/DynamicJsonForm";
import { JsonObject } from "./jsonPathUtils";
import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils";
/**
* Generates a default value based on a JSON schema type

42
package-lock.json generated
View File

@@ -1,20 +1,20 @@
{
"name": "@modelcontextprotocol/inspector",
"version": "0.8.1",
"version": "0.8.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@modelcontextprotocol/inspector",
"version": "0.8.1",
"version": "0.8.2",
"license": "MIT",
"workspaces": [
"client",
"server"
],
"dependencies": {
"@modelcontextprotocol/inspector-client": "^0.8.1",
"@modelcontextprotocol/inspector-server": "^0.8.1",
"@modelcontextprotocol/inspector-client": "^0.8.2",
"@modelcontextprotocol/inspector-server": "^0.8.2",
"concurrently": "^9.0.1",
"shell-quote": "^1.8.2",
"spawn-rx": "^5.1.2",
@@ -32,10 +32,10 @@
},
"client": {
"name": "@modelcontextprotocol/inspector-client",
"version": "0.8.1",
"version": "0.8.2",
"license": "MIT",
"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",
@@ -46,7 +46,6 @@
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"@types/prismjs": "^1.26.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
@@ -70,6 +69,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",
@@ -1329,9 +1329,9 @@
"link": true
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.8.0.tgz",
"integrity": "sha512-e06W7SwrontJDHwCawNO5SGxG+nU9AAx+jpHHZqGl/WrDBdWOpvirC+s58VpJTB5QemI4jTRcjWT4Pt3Q1NPQQ==",
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.9.0.tgz",
"integrity": "sha512-Jq2EUCQpe0iyO5FGpzVYDNFR6oR53AIrwph9yWl7uSc7IWUMsrmpmSaTGra5hQNunXpM+9oit85p924jWuHzUA==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
@@ -1340,7 +1340,7 @@
"eventsource": "^3.0.2",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"pkce-challenge": "^4.1.0",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.24.1"
@@ -1585,6 +1585,15 @@
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/pkce-challenge": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz",
"integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==",
"license": "MIT",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/send": {
"version": "1.1.0",
"license": "MIT",
@@ -3566,6 +3575,9 @@
},
"node_modules/@types/prismjs": {
"version": "1.26.5",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz",
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/prop-types": {
@@ -9124,7 +9136,9 @@
}
},
"node_modules/vite": {
"version": "5.4.16",
"version": "5.4.17",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.17.tgz",
"integrity": "sha512-5+VqZryDj4wgCs55o9Lp+p8GE78TLVg0lasCH5xFZ4jacZjtqZa6JUw9/p0WeAojaOfncSM6v77InkFPGnvPvg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9486,10 +9500,10 @@
},
"server": {
"name": "@modelcontextprotocol/inspector-server",
"version": "0.8.1",
"version": "0.8.2",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.8.0",
"@modelcontextprotocol/sdk": "^1.9.0",
"cors": "^2.8.5",
"express": "^4.21.0",
"ws": "^8.18.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector",
"version": "0.8.1",
"version": "0.8.2",
"description": "Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -36,8 +36,8 @@
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
},
"dependencies": {
"@modelcontextprotocol/inspector-client": "^0.8.1",
"@modelcontextprotocol/inspector-server": "^0.8.1",
"@modelcontextprotocol/inspector-client": "^0.8.2",
"@modelcontextprotocol/inspector-server": "^0.8.2",
"concurrently": "^9.0.1",
"shell-quote": "^1.8.2",
"spawn-rx": "^5.1.2",

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-server",
"version": "0.8.1",
"version": "0.8.2",
"description": "Server-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -27,7 +27,7 @@
"typescript": "^5.6.2"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.8.0",
"@modelcontextprotocol/sdk": "^1.9.0",
"cors": "^2.8.5",
"express": "^4.21.0",
"ws": "^8.18.0",