Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-73ea615029

This commit is contained in:
Cliff Hall 2025-10-10 17:25:14 -04:00 • committed by GitHub
commit 95f16e3a3a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 3451 additions and 914 deletions

View file

@ -1,13 +1,13 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
title: ""
labels: ""
assignees: ""
---
**Inspector Version**
- [e.g. 0.16.5)
**Describe the bug**
@ -15,6 +15,7 @@ A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
@ -27,8 +28,9 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
**Additional context**
Add any other context about the problem here.

33
AGENTS.md Normal file
View file

@ -0,0 +1,33 @@
# MCP Inspector Development Guide
## Build Commands
- Build all: `npm run build`
- Build client: `npm run build-client`
- Build server: `npm run build-server`
- Development mode: `npm run dev` (use `npm run dev:windows` on Windows)
- Format code: `npm run prettier-fix`
- Client lint: `cd client && npm run lint`
## Code Style Guidelines
- Use TypeScript with proper type annotations
- Follow React functional component patterns with hooks
- Use ES modules (import/export) not CommonJS
- Use Prettier for formatting (auto-formatted on commit)
- Follow existing naming conventions:
- camelCase for variables and functions
- PascalCase for component names and types
- kebab-case for file names
- Use async/await for asynchronous operations
- Implement proper error handling with try/catch blocks
- Use Tailwind CSS for styling in the client
- Keep components small and focused on a single responsibility
## Project Organization
The project is organized as a monorepo with workspaces:
- `client/`: React frontend with Vite, TypeScript and Tailwind
- `server/`: Express backend with TypeScript
- `cli/`: Command-line interface for testing and invoking MCP server methods directly

View file

@ -1,33 +1 @@
# MCP Inspector Development Guide
## Build Commands
- Build all: `npm run build`
- Build client: `npm run build-client`
- Build server: `npm run build-server`
- Development mode: `npm run dev` (use `npm run dev:windows` on Windows)
- Format code: `npm run prettier-fix`
- Client lint: `cd client && npm run lint`
## Code Style Guidelines
- Use TypeScript with proper type annotations
- Follow React functional component patterns with hooks
- Use ES modules (import/export) not CommonJS
- Use Prettier for formatting (auto-formatted on commit)
- Follow existing naming conventions:
- camelCase for variables and functions
- PascalCase for component names and types
- kebab-case for file names
- Use async/await for asynchronous operations
- Implement proper error handling with try/catch blocks
- Use Tailwind CSS for styling in the client
- Keep components small and focused on a single responsibility
## Project Organization
The project is organized as a monorepo with workspaces:
- `client/`: React frontend with Vite, TypeScript and Tailwind
- `server/`: Express backend with TypeScript
- `cli/`: Command-line interface for testing and invoking MCP server methods directly
@./AGENTS.md

View file

@ -98,6 +98,16 @@ The MCP Inspector provides convenient buttons to export server launch configurat
}
```
**Streamable HTTP transport example:**
```json
{
"type": "streamable-http",
"url": "http://localhost:3000/mcp",
"note": "For Streamable HTTP connections, add this URL directly in your MCP Client"
}
```
- **Servers File** - Copies a complete MCP configuration file structure to your clipboard, with your current server configuration added as `default-server`. This can be saved directly as `mcp.json`.
**STDIO transport example:**
@ -131,9 +141,23 @@ The MCP Inspector provides convenient buttons to export server launch configurat
}
```
**Streamable HTTP transport example:**
```json
{
"mcpServers": {
"default-server": {
"type": "streamable-http",
"url": "http://localhost:3000/mcp",
"note": "For Streamable HTTP connections, add this URL directly in your MCP Client"
}
}
}
```
These buttons appear in the Inspector UI after you've configured your server settings, making it easy to save and reuse your configurations.
For SSE transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the SSE URL configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the SSE URL for direct use in clients.
For SSE and Streamable HTTP transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the URL for direct use in clients.
You can paste the Server Entry into your existing `mcp.json` file under your chosen server name, or use the complete Servers File payload to create a new configuration file.

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-cli",
"version": "0.16.6",
"version": "0.17.0",
"description": "CLI for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@ -24,7 +24,7 @@
},
"devDependencies": {},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.5",
"@modelcontextprotocol/sdk": "^1.18.0",
"commander": "^13.1.0",
"spawn-rx": "^5.1.2"
}

View file

@ -1,5 +1,6 @@
#!/usr/bin/env node
import * as fs from "fs";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { Command } from "commander";
import {
@ -99,16 +100,27 @@ function createTransportOptions(
}
async function callMethod(args: Args): Promise<void> {
// Read package.json to get name and version for client identity
const pathA = "../package.json"; // We're in package @modelcontextprotocol/inspector-cli
const pathB = "../../package.json"; // We're in package @modelcontextprotocol/inspector
let packageJson: { name: string; version: string };
let packageJsonData = await import(fs.existsSync(pathA) ? pathA : pathB, {
with: { type: "json" },
});
packageJson = packageJsonData.default;
const transportOptions = createTransportOptions(
args.target,
args.transport,
args.headers,
);
const transport = createTransport(transportOptions);
const client = new Client({
name: "inspector-cli",
version: "0.5.1",
});
const [, name = packageJson.name] = packageJson.name.split("/");
const version = packageJson.version;
const clientIdentity = { name, version };
const client = new Client(clientIdentity);
try {
await connect(client, transport);

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.16.6",
"version": "0.17.0",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@ -25,7 +25,7 @@
"cleanup:e2e": "node e2e/global-teardown.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.5",
"@modelcontextprotocol/sdk": "^1.18.0",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0",
@ -33,6 +33,7 @@
"@radix-ui/react-popover": "^1.1.3",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
@ -69,6 +70,7 @@
"globals": "^15.9.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fixed-jsdom": "^0.0.9",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.13",
"tailwindcss-animate": "^1.0.7",

View file

@ -22,6 +22,8 @@ import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
import { OAuthStateMachine } from "./lib/oauth-state-machine";
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
import { cleanParams } from "./utils/paramUtils";
import type { JsonSchemaType } from "./utils/jsonUtils";
import React, {
Suspense,
useCallback,
@ -74,6 +76,10 @@ import ElicitationTab, {
PendingElicitationRequest,
ElicitationResponse,
} from "./components/ElicitationTab";
import {
CustomHeaders,
migrateFromLegacyAuth,
} from "./lib/types/customHeaders";
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
@ -103,6 +109,14 @@ const App = () => {
const [transportType, setTransportType] = useState<
"stdio" | "sse" | "streamable-http"
>(getInitialTransportType);
const [connectionType, setConnectionType] = useState<"direct" | "proxy">(
() => {
return (
(localStorage.getItem("lastConnectionType") as "direct" | "proxy") ||
"proxy"
);
},
);
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const [roots, setRoots] = useState<Root[]>([]);
@ -127,6 +141,43 @@ const App = () => {
return localStorage.getItem("lastOauthScope") || "";
});
const [oauthClientSecret, setOauthClientSecret] = useState<string>(() => {
return localStorage.getItem("lastOauthClientSecret") || "";
});
// Custom headers state with migration from legacy auth
const [customHeaders, setCustomHeaders] = useState<CustomHeaders>(() => {
const savedHeaders = localStorage.getItem("lastCustomHeaders");
if (savedHeaders) {
try {
return JSON.parse(savedHeaders);
} catch (error) {
console.warn(
`Failed to parse custom headers: "${savedHeaders}", will try legacy migration`,
error,
);
// Fall back to migration if JSON parsing fails
}
}
// Migrate from legacy auth if available
const legacyToken = localStorage.getItem("lastBearerToken") || "";
const legacyHeaderName = localStorage.getItem("lastHeaderName") || "";
if (legacyToken) {
return migrateFromLegacyAuth(legacyToken, legacyHeaderName);
}
// Default to empty array
return [
{
name: "Authorization",
value: "Bearer ",
enabled: false,
},
];
});
const [pendingSampleRequests, setPendingSampleRequests] = useState<
Array<
PendingRequest & {
@ -213,11 +264,12 @@ const App = () => {
args,
sseUrl,
env,
bearerToken,
headerName,
customHeaders,
oauthClientId,
oauthClientSecret,
oauthScope,
config,
connectionType,
onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]);
},
@ -303,13 +355,42 @@ const App = () => {
}, [transportType]);
useEffect(() => {
localStorage.setItem("lastBearerToken", bearerToken);
localStorage.setItem("lastConnectionType", connectionType);
}, [connectionType]);
useEffect(() => {
if (bearerToken) {
localStorage.setItem("lastBearerToken", bearerToken);
} else {
localStorage.removeItem("lastBearerToken");
}
}, [bearerToken]);
useEffect(() => {
localStorage.setItem("lastHeaderName", headerName);
if (headerName) {
localStorage.setItem("lastHeaderName", headerName);
} else {
localStorage.removeItem("lastHeaderName");
}
}, [headerName]);
useEffect(() => {
localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders));
}, [customHeaders]);
// Auto-migrate from legacy auth when custom headers are empty but legacy auth exists
useEffect(() => {
if (customHeaders.length === 0 && (bearerToken || headerName)) {
const migratedHeaders = migrateFromLegacyAuth(bearerToken, headerName);
if (migratedHeaders.length > 0) {
setCustomHeaders(migratedHeaders);
// Clear legacy auth after migration
setBearerToken("");
setHeaderName("");
}
}
}, [bearerToken, headerName, customHeaders, setCustomHeaders]);
useEffect(() => {
localStorage.setItem("lastOauthClientId", oauthClientId);
}, [oauthClientId]);
@ -318,6 +399,10 @@ const App = () => {
localStorage.setItem("lastOauthScope", oauthScope);
}, [oauthScope]);
useEffect(() => {
localStorage.setItem("lastOauthClientSecret", oauthClientSecret);
}, [oauthClientSecret]);
useEffect(() => {
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
}, [config]);
@ -703,12 +788,18 @@ const App = () => {
lastToolCallOriginTabRef.current = currentTabRef.current;
try {
// Find the tool schema to clean parameters properly
const tool = tools.find((t) => t.name === name);
const cleanedParams = tool?.inputSchema
? cleanParams(params, tool.inputSchema as JsonSchemaType)
: params;
const response = await sendMCPRequest(
{
method: "tools/call" as const,
params: {
name,
arguments: params,
arguments: cleanedParams,
_meta: {
progressToken: progressTokenRef.current++,
},
@ -719,6 +810,8 @@ const App = () => {
);
setToolResult(response);
// Clear any validation errors since tool execution completed
setErrors((prev) => ({ ...prev, tools: null }));
} catch (e) {
const toolResult: CompatibilityCallToolResult = {
content: [
@ -730,6 +823,8 @@ const App = () => {
isError: true,
};
setToolResult(toolResult);
// Clear validation errors - tool execution errors are shown in ToolResults
setErrors((prev) => ({ ...prev, tools: null }));
}
};
@ -810,12 +905,12 @@ const App = () => {
setEnv={setEnv}
config={config}
setConfig={setConfig}
bearerToken={bearerToken}
setBearerToken={setBearerToken}
headerName={headerName}
setHeaderName={setHeaderName}
customHeaders={customHeaders}
setCustomHeaders={setCustomHeaders}
oauthClientId={oauthClientId}
setOauthClientId={setOauthClientId}
oauthClientSecret={oauthClientSecret}
setOauthClientSecret={setOauthClientSecret}
oauthScope={oauthScope}
setOauthScope={setOauthScope}
onConnect={connectMcpServer}
@ -823,6 +918,8 @@ const App = () => {
logLevel={logLevel}
sendLogLevelRequest={sendLogLevelRequest}
loggingSupported={!!serverCapabilities?.logging || false}
connectionType={connectionType}
setConnectionType={setConnectionType}
/>
<div
onMouseDown={handleSidebarDragStart}

View file

@ -0,0 +1,241 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Plus, Trash2, Eye, EyeOff } from "lucide-react";
import {
CustomHeaders as CustomHeadersType,
CustomHeader,
createEmptyHeader,
} from "@/lib/types/customHeaders";
interface CustomHeadersProps {
headers: CustomHeadersType;
onChange: (headers: CustomHeadersType) => void;
className?: string;
}
const CustomHeaders = ({
headers,
onChange,
className,
}: CustomHeadersProps) => {
const [isJsonMode, setIsJsonMode] = useState(false);
const [jsonValue, setJsonValue] = useState("");
const [jsonError, setJsonError] = useState<string | null>(null);
const [visibleValues, setVisibleValues] = useState<Set<number>>(new Set());
const updateHeader = (
index: number,
field: keyof CustomHeader,
value: string | boolean,
) => {
const newHeaders = [...headers];
newHeaders[index] = { ...newHeaders[index], [field]: value };
onChange(newHeaders);
};
const addHeader = () => {
onChange([...headers, createEmptyHeader()]);
};
const removeHeader = (index: number) => {
const newHeaders = headers.filter((_, i) => i !== index);
onChange(newHeaders);
};
const toggleValueVisibility = (index: number) => {
const newVisible = new Set(visibleValues);
if (newVisible.has(index)) {
newVisible.delete(index);
} else {
newVisible.add(index);
}
setVisibleValues(newVisible);
};
const switchToJsonMode = () => {
const jsonObject: Record<string, string> = {};
headers.forEach((header) => {
if (header.enabled && header.name.trim() && header.value.trim()) {
jsonObject[header.name.trim()] = header.value.trim();
}
});
setJsonValue(JSON.stringify(jsonObject, null, 2));
setJsonError(null);
setIsJsonMode(true);
};
const switchToFormMode = () => {
try {
const parsed = JSON.parse(jsonValue);
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
setJsonError("JSON must be an object with string key-value pairs");
return;
}
const newHeaders: CustomHeadersType = Object.entries(parsed).map(
([name, value]) => ({
name,
value: String(value),
enabled: true,
}),
);
onChange(newHeaders);
setJsonError(null);
setIsJsonMode(false);
} catch {
setJsonError("Invalid JSON format");
}
};
const handleJsonChange = (value: string) => {
setJsonValue(value);
setJsonError(null);
};
if (isJsonMode) {
return (
<div className={`space-y-3 ${className}`}>
<div className="flex justify-between items-center gap-2">
<h4 className="text-sm font-semibold flex-shrink-0">
Custom Headers (JSON)
</h4>
<Button
type="button"
variant="outline"
size="sm"
onClick={switchToFormMode}
className="flex-shrink-0"
>
Switch to Form
</Button>
</div>
<div className="space-y-2">
<Textarea
value={jsonValue}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder='{\n "Authorization": "Bearer token123",\n "X-Tenant-ID": "acme-inc",\n "X-Environment": "staging"\n}'
className="font-mono text-sm min-h-[100px] resize-none"
/>
{jsonError && <p className="text-sm text-red-600">{jsonError}</p>}
<p className="text-xs text-muted-foreground">
Enter headers as a JSON object with string key-value pairs.
</p>
</div>
</div>
);
}
return (
<div className={`space-y-3 ${className}`}>
<div className="flex justify-between items-center gap-2">
<h4 className="text-sm font-semibold flex-shrink-0">Custom Headers</h4>
<div className="flex gap-1 flex-shrink-0">
<Button
type="button"
variant="outline"
size="sm"
onClick={switchToJsonMode}
className="text-xs px-2"
>
JSON
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={addHeader}
className="text-xs px-2"
data-testid="add-header-button"
>
<Plus className="w-3 h-3 mr-1" />
Add
</Button>
</div>
</div>
{headers.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">
<p className="text-sm">No custom headers configured</p>
<p className="text-xs mt-1">Click "Add" to get started</p>
</div>
) : (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{headers.map((header, index) => (
<div
key={index}
className="flex items-start gap-2 p-2 border rounded-md"
>
<Switch
checked={header.enabled}
onCheckedChange={(enabled) =>
updateHeader(index, "enabled", enabled)
}
className="shrink-0 mt-2"
/>
<div className="flex-1 min-w-0 space-y-2">
<Input
placeholder="Header Name"
value={header.name}
onChange={(e) => updateHeader(index, "name", e.target.value)}
className="font-mono text-xs"
data-testid={`header-name-input-${index}`}
/>
<div className="relative">
<Input
placeholder="Header Value"
value={header.value}
onChange={(e) =>
updateHeader(index, "value", e.target.value)
}
type={visibleValues.has(index) ? "text" : "password"}
className="font-mono text-xs pr-8"
data-testid={`header-value-input-${index}`}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => toggleValueVisibility(index)}
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
>
{visibleValues.has(index) ? (
<EyeOff className="w-3 h-3" />
) : (
<Eye className="w-3 h-3" />
)}
</Button>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeHeader(index)}
className="shrink-0 text-red-600 hover:text-red-700 hover:bg-red-50 h-6 w-6 p-0 mt-2"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
</div>
)}
{headers.length > 0 && (
<p className="text-xs text-muted-foreground">
Use the toggle to enable/disable headers. Only enabled headers with
both name and value will be sent.
</p>
)}
</div>
);
};
export default CustomHeaders;

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,11 @@
import { useState, memo, useMemo, useCallback, useEffect } from "react";
import { useState, memo, useMemo, useCallback } from "react";
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 "@/lib/hooks/useToast";
import { getDataType, tryParseJson } from "@/utils/jsonUtils";
import useCopy from "@/lib/hooks/useCopy";
interface JsonViewProps {
data: unknown;
@ -25,21 +26,7 @@ const JsonView = memo(
isError = false,
}: JsonViewProps) => {
const { toast } = useToast();
const [copied, setCopied] = useState(false);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (copied) {
timeoutId = setTimeout(() => {
setCopied(false);
}, 500);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [copied]);
const { copied, setCopied } = useCopy();
const normalizedData = useMemo(() => {
return typeof data === "string"

View file

@ -1,4 +1,7 @@
import { Search } from "lucide-react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { useState, useMemo, useRef } from "react";
type ListPaneProps<T> = {
items: T[];
@ -20,41 +23,111 @@ const ListPane = <T extends object>({
title,
buttonText,
isButtonDisabled,
}: ListPaneProps<T>) => (
<div className="bg-card border border-border rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-border">
<h3 className="font-semibold dark:text-white">{title}</h3>
</div>
<div className="p-4">
<Button
variant="outline"
className="w-full mb-4"
onClick={listItems}
disabled={isButtonDisabled}
>
{buttonText}
</Button>
<Button
variant="outline"
className="w-full mb-4"
onClick={clearItems}
disabled={items.length === 0}
>
Clear
</Button>
<div className="space-y-2 overflow-y-auto max-h-96">
{items.map((item, index) => (
<div
key={index}
className="flex items-center py-2 px-4 rounded hover:bg-gray-50 dark:hover:bg-secondary cursor-pointer"
onClick={() => setSelectedItem(item)}
>
{renderItem(item)}
}: ListPaneProps<T>) => {
const [searchQuery, setSearchQuery] = useState("");
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const filteredItems = useMemo(() => {
if (!searchQuery.trim()) return items;
return items.filter((item) => {
const searchableText = [
(item as { name?: string }).name || "",
(item as { description?: string }).description || "",
]
.join(" ")
.toLowerCase();
return searchableText.includes(searchQuery.toLowerCase());
});
}, [items, searchQuery]);
const handleSearchClick = () => {
setIsSearchExpanded(true);
setTimeout(() => {
searchInputRef.current?.focus();
}, 100);
};
const handleSearchBlur = () => {
if (!searchQuery.trim()) {
setIsSearchExpanded(false);
}
};
return (
<div className="bg-card border border-border rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-border">
<div className="flex items-center justify-between gap-4">
<h3 className="font-semibold dark:text-white flex-shrink-0">
{title}
</h3>
<div className="flex items-center justify-end min-w-0 flex-1">
{!isSearchExpanded ? (
<button
name="search"
aria-label="Search"
onClick={handleSearchClick}
className="p-2 hover:bg-gray-100 dark:hover:bg-secondary rounded-md transition-all duration-300 ease-in-out"
>
<Search className="w-4 h-4 text-muted-foreground" />
</button>
) : (
<div className="flex items-center w-full max-w-xs">
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none z-10" />
<Input
ref={searchInputRef}
name="search"
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
className="pl-10 w-full transition-all duration-300 ease-in-out"
/>
</div>
</div>
)}
</div>
))}
</div>
</div>
<div className="p-4">
<Button
variant="outline"
className="w-full mb-4"
onClick={listItems}
disabled={isButtonDisabled}
>
{buttonText}
</Button>
<Button
variant="outline"
className="w-full mb-4"
onClick={clearItems}
disabled={items.length === 0}
>
Clear
</Button>
<div className="space-y-2 overflow-y-auto max-h-96">
{filteredItems.map((item, index) => (
<div
key={index}
className="flex items-center py-2 px-4 rounded hover:bg-gray-50 dark:hover:bg-secondary cursor-pointer"
onClick={() => setSelectedItem(item)}
>
{renderItem(item)}
</div>
))}
{filteredItems.length === 0 && searchQuery && items.length > 0 && (
<div className="text-center py-4 text-muted-foreground">
No items found matching &quot;{searchQuery}&quot;
</div>
)}
</div>
</div>
</div>
</div>
);
);
};
export default ListPane;

View file

@ -181,8 +181,8 @@ export const OAuthFlowProgress = ({
</p>
<p className="text-xs text-blue-600 mt-1">
Resource metadata was added in the{" "}
<a href="https://modelcontextprotocol.io/specification/draft/basic/authorization#2-3-1-authorization-server-location">
2025-DRAFT-v2 specification update
<a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location">
2025-06-18 specification update
</a>
<br />
{authState.resourceMetadataError.message}

View file

@ -63,9 +63,7 @@ const PromptsTab = ({
clearCompletions();
}, [clearCompletions, selectedPrompt]);
const handleInputChange = async (argName: string, value: string) => {
setPromptArgs((prev) => ({ ...prev, [argName]: value }));
const triggerCompletions = (argName: string, value: string) => {
if (selectedPrompt) {
requestCompletions(
{
@ -79,6 +77,16 @@ const PromptsTab = ({
}
};
const handleInputChange = async (argName: string, value: string) => {
setPromptArgs((prev) => ({ ...prev, [argName]: value }));
triggerCompletions(argName, value);
};
const handleFocus = async (argName: string) => {
const currentValue = promptArgs[argName] || "";
triggerCompletions(argName, currentValue);
};
const handleGetPrompt = () => {
if (selectedPrompt) {
getPrompt(selectedPrompt.name, promptArgs);
@ -123,7 +131,9 @@ const PromptsTab = ({
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
<AlertDescription className="break-all">
{error}
</AlertDescription>
</Alert>
) : selectedPrompt ? (
<div className="space-y-4">
@ -143,6 +153,7 @@ const PromptsTab = ({
onInputChange={(value) =>
handleInputChange(arg.name, value)
}
onFocus={() => handleFocus(arg.name)}
options={completions[arg.name] || []}
/>

View file

@ -225,7 +225,9 @@ const ResourcesTab = ({
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
<AlertDescription className="break-all">
{error}
</AlertDescription>
</Alert>
) : selectedResource ? (
<JsonView

View file

@ -37,6 +37,8 @@ import {
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import CustomHeaders from "./CustomHeaders";
import { CustomHeaders as CustomHeadersType } from "@/lib/types/customHeaders";
import { useToast } from "../lib/hooks/useToast";
interface SidebarProps {
@ -51,12 +53,13 @@ interface SidebarProps {
setSseUrl: (url: string) => void;
env: Record<string, string>;
setEnv: (env: Record<string, string>) => void;
bearerToken: string;
setBearerToken: (token: string) => void;
headerName?: string;
setHeaderName?: (name: string) => void;
// Custom headers support
customHeaders: CustomHeadersType;
setCustomHeaders: (headers: CustomHeadersType) => void;
oauthClientId: string;
setOauthClientId: (id: string) => void;
oauthClientSecret: string;
setOauthClientSecret: (secret: string) => void;
oauthScope: string;
setOauthScope: (scope: string) => void;
onConnect: () => void;
@ -66,6 +69,8 @@ interface SidebarProps {
loggingSupported: boolean;
config: InspectorConfig;
setConfig: (config: InspectorConfig) => void;
connectionType: "direct" | "proxy";
setConnectionType: (type: "direct" | "proxy") => void;
}
const Sidebar = ({
@ -80,12 +85,12 @@ const Sidebar = ({
setSseUrl,
env,
setEnv,
bearerToken,
setBearerToken,
headerName,
setHeaderName,
customHeaders,
setCustomHeaders,
oauthClientId,
setOauthClientId,
oauthClientSecret,
setOauthClientSecret,
oauthScope,
setOauthScope,
onConnect,
@ -95,16 +100,21 @@ const Sidebar = ({
loggingSupported,
config,
setConfig,
connectionType,
setConnectionType,
}: SidebarProps) => {
const [theme, setTheme] = useTheme();
const [showEnvVars, setShowEnvVars] = useState(false);
const [showAuthConfig, setShowAuthConfig] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
const [showClientSecret, setShowClientSecret] = useState(false);
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
const [copiedServerFile, setCopiedServerFile] = useState(false);
const { toast } = useToast();
const connectionTypeTip =
"Connect to server directly (requires CORS config on server) or via MCP Inspector Proxy";
// Reusable error reporter for copy actions
const reportError = useCallback(
(error: unknown) => {
@ -175,7 +185,9 @@ const Sidebar = ({
description:
transportType === "stdio"
? "Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name."
: "SSE URL has been copied. Use this URL directly in your MCP Client.",
: transportType === "streamable-http"
? "Streamable HTTP URL has been copied. Use this URL directly in your MCP Client."
: "SSE URL has been copied. Use this URL directly in your MCP Client.",
});
setTimeout(() => {
@ -312,6 +324,35 @@ const Sidebar = ({
/>
)}
</div>
{/* Connection Type switch - only visible for non-STDIO transport types */}
<Tooltip>
<TooltipTrigger asChild>
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor="connection-type-select"
>
Connection Type
</label>
<Select
value={connectionType}
onValueChange={(value: "direct" | "proxy") =>
setConnectionType(value)
}
>
<SelectTrigger id="connection-type-select">
<SelectValue placeholder="Select connection type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="proxy">Via Proxy</SelectItem>
<SelectItem value="direct">Direct</SelectItem>
</SelectContent>
</Select>
</div>
</TooltipTrigger>
<TooltipContent>{connectionTypeTip}</TooltipContent>
</Tooltip>
</>
)}
@ -497,38 +538,12 @@ const Sidebar = ({
</Button>
{showAuthConfig && (
<>
{/* Bearer Token Section */}
<div className="space-y-2 p-3 rounded border">
<h4 className="text-sm font-semibold flex items-center">
API Token Authentication
</h4>
<div className="space-y-2">
<label className="text-sm font-medium">Header Name</label>
<Input
placeholder="Authorization"
onChange={(e) =>
setHeaderName && setHeaderName(e.target.value)
}
data-testid="header-input"
className="font-mono"
value={headerName}
/>
<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)}
data-testid="bearer-token-input"
className="font-mono"
type="password"
/>
</div>
{/* Custom Headers Section */}
<div className="p-3 rounded border overflow-hidden">
<CustomHeaders
headers={customHeaders}
onChange={setCustomHeaders}
/>
</div>
{transportType !== "stdio" && (
// OAuth Configuration
@ -545,6 +560,38 @@ const Sidebar = ({
data-testid="oauth-client-id-input"
className="font-mono"
/>
<label className="text-sm font-medium">
Client Secret
</label>
<div className="flex gap-2">
<Input
type={showClientSecret ? "text" : "password"}
placeholder="Client Secret (optional)"
onChange={(e) => setOauthClientSecret(e.target.value)}
value={oauthClientSecret}
data-testid="oauth-client-secret-input"
className="font-mono"
/>
<Button
variant="outline"
size="icon"
className="h-9 w-9 p-0 shrink-0"
onClick={() => setShowClientSecret(!showClientSecret)}
aria-label={
showClientSecret ? "Hide secret" : "Show secret"
}
aria-pressed={showClientSecret}
title={
showClientSecret ? "Hide secret" : "Show secret"
}
>
{showClientSecret ? (
<Eye className="h-4 w-4" aria-hidden="true" />
) : (
<EyeOff className="h-4 w-4" aria-hidden="true" />
)}
</Button>
</div>
<label className="text-sm font-medium">
Redirect URL
</label>

View file

@ -1,11 +1,18 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
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 from "./DynamicJsonForm";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import {
generateDefaultValue,
@ -17,11 +24,21 @@ import {
ListToolsResult,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { Loader2, Send, ChevronDown, ChevronUp } from "lucide-react";
import { useEffect, useState } from "react";
import {
Loader2,
Send,
ChevronDown,
ChevronUp,
AlertCircle,
Copy,
CheckCheck,
} from "lucide-react";
import { useEffect, useState, useRef } from "react";
import ListPane from "./ListPane";
import JsonView from "./JsonView";
import ToolResults from "./ToolResults";
import { useToast } from "@/lib/hooks/useToast";
import useCopy from "@/lib/hooks/useCopy";
// Type guard to safely detect the optional _meta field without using `any`
const hasMeta = (tool: Tool): tool is Tool & { _meta: unknown } =>
@ -36,6 +53,7 @@ const ToolsTab = ({
setSelectedTool,
toolResult,
nextCursor,
error,
resourceContent,
onReadResource,
}: {
@ -55,6 +73,19 @@ const ToolsTab = ({
const [isToolRunning, setIsToolRunning] = useState(false);
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
const [isMetaExpanded, setIsMetaExpanded] = useState(false);
const [hasValidationErrors, setHasValidationErrors] = useState(false);
const formRefs = useRef<Record<string, DynamicJsonFormRef | null>>({});
const { toast } = useToast();
const { copied, setCopied } = useCopy();
// Function to check if any form has validation errors
const checkValidationErrors = () => {
const errors = Object.values(formRefs.current).some(
(ref) => ref && !ref.validateJson().isValid,
);
setHasValidationErrors(errors);
return errors;
};
useEffect(() => {
const params = Object.entries(
@ -68,6 +99,12 @@ const ToolsTab = ({
),
]);
setParams(Object.fromEntries(params));
// Reset validation errors when switching tools
setHasValidationErrors(false);
// Clear form refs for the previous tool
formRefs.current = {};
}, [selectedTool]);
return (
@ -84,7 +121,7 @@ const ToolsTab = ({
renderItem={(tool) => (
<div className="flex flex-col items-start">
<span className="flex-1">{tool.name}</span>
<span className="text-sm text-gray-500 text-left">
<span className="text-sm text-gray-500 text-left line-clamp-3">
{tool.description}
</span>
</div>
@ -103,7 +140,16 @@ const ToolsTab = ({
<div className="p-4">
{selectedTool ? (
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription className="break-all">
{error}
</AlertDescription>
</Alert>
)}
<p className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap max-h-48 overflow-y-auto">
{selectedTool.description}
</p>
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
@ -143,26 +189,74 @@ const ToolsTab = ({
{prop.description || "Toggle this option"}
</label>
</div>
) : prop.type === "string" && prop.enum ? (
<Select
value={
params[key] === undefined
? ""
: String(params[key])
}
onValueChange={(value) => {
if (value === "") {
setParams({
...params,
[key]: undefined,
});
} else {
setParams({
...params,
[key]: value,
});
}
}}
>
<SelectTrigger id={key} className="mt-1">
<SelectValue
placeholder={
prop.description || "Select an option"
}
/>
</SelectTrigger>
<SelectContent>
{prop.enum.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
) : prop.type === "string" ? (
<Textarea
id={key}
name={key}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) =>
setParams({
...params,
[key]:
e.target.value === ""
? undefined
: e.target.value,
})
value={
params[key] === undefined
? ""
: String(params[key])
}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Field cleared - set to undefined
setParams({
...params,
[key]: undefined,
});
} else {
// Field has value - keep as string
setParams({
...params,
[key]: value,
});
}
}}
className="mt-1"
/>
) : prop.type === "object" || prop.type === "array" ? (
<div className="mt-1">
<DynamicJsonForm
ref={(ref) => (formRefs.current[key] = ref)}
schema={{
type: prop.type,
properties: prop.properties,
@ -178,6 +272,8 @@ const ToolsTab = ({
...params,
[key]: newValue,
});
// Check validation after a short delay to allow form to update
setTimeout(checkValidationErrors, 100);
}}
/>
</div>
@ -188,19 +284,42 @@ const ToolsTab = ({
id={key}
name={key}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
value={
params[key] === undefined
? ""
: String(params[key])
}
onChange={(e) => {
const value = e.target.value;
setParams({
...params,
[key]: value === "" ? "" : Number(value),
});
if (value === "") {
// Field cleared - set to undefined
setParams({
...params,
[key]: undefined,
});
} else {
// Field has value - try to convert to number, but store input either way
const num = Number(value);
if (!isNaN(num)) {
setParams({
...params,
[key]: num,
});
} else {
// Store invalid input as string - let server validate
setParams({
...params,
[key]: value,
});
}
}
}}
className="mt-1"
/>
) : (
<div className="mt-1">
<DynamicJsonForm
ref={(ref) => (formRefs.current[key] = ref)}
schema={{
type: prop.type,
properties: prop.properties,
@ -213,6 +332,8 @@ const ToolsTab = ({
...params,
[key]: newValue,
});
// Check validation after a short delay to allow form to update
setTimeout(checkValidationErrors, 100);
}}
/>
</div>
@ -291,29 +412,57 @@ const ToolsTab = ({
</div>
</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>
<div className="flex gap-2">
<Button
onClick={async () => {
// Validate JSON inputs before calling tool
if (checkValidationErrors()) return;
try {
setIsToolRunning(true);
await callTool(selectedTool.name, params);
} finally {
setIsToolRunning(false);
}
}}
disabled={isToolRunning || hasValidationErrors}
>
{isToolRunning ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Running...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Run Tool
</>
)}
</Button>
<Button
onClick={async () => {
try {
navigator.clipboard.writeText(
JSON.stringify(params, null, 2),
);
setCopied(true);
} catch (error) {
toast({
title: "Error",
description: `There was an error copying input to the clipboard: ${error instanceof Error ? error.message : String(error)}`,
variant: "destructive",
});
}
}}
>
{copied ? (
<CheckCheck className="h-4 w-4 mr-2 dark:text-green-700 text-green-600" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
Copy Input
</Button>
</div>
<ToolResults
toolResult={toolResult}
selectedTool={selectedTool}

View file

@ -1,7 +1,8 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm";
import { useRef } from "react";
import DynamicJsonForm, { DynamicJsonFormRef } from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils";
describe("DynamicJsonForm String Fields", () => {
@ -663,3 +664,221 @@ describe("DynamicJsonForm Copy JSON Functionality", () => {
});
});
});
describe("DynamicJsonForm Validation Functionality", () => {
const renderFormWithRef = (props = {}) => {
const TestComponent = () => {
const formRef = useRef<DynamicJsonFormRef>(null);
const defaultProps = {
schema: {
type: "object",
properties: {
nested: { oneOf: [{ type: "string" }, { type: "integer" }] },
},
} as unknown as JsonSchemaType,
value: { nested: "test value" },
onChange: jest.fn(),
ref: formRef,
};
return (
<div>
<DynamicJsonForm {...defaultProps} {...props} />
<button
onClick={() => {
const result = formRef.current?.validateJson();
// Add data attributes to make validation result testable
const button = document.querySelector(
'[data-testid="validate-button"]',
) as HTMLElement;
if (button && result) {
button.setAttribute(
"data-validation-valid",
result.isValid.toString(),
);
button.setAttribute(
"data-validation-error",
result.error || "",
);
}
}}
data-testid="validate-button"
>
Validate
</button>
</div>
);
};
return render(<TestComponent />);
};
describe("validateJson method", () => {
it("should return valid for form mode", () => {
const simpleSchema = {
type: "string" as const,
description: "Test string field",
};
const TestComponent = () => {
const formRef = useRef<DynamicJsonFormRef>(null);
return (
<div>
<DynamicJsonForm
ref={formRef}
schema={simpleSchema}
value="test"
onChange={jest.fn()}
/>
<button
onClick={() => {
const result = formRef.current?.validateJson();
const button = document.querySelector(
'[data-testid="validate-button"]',
) as HTMLElement;
if (button && result) {
button.setAttribute(
"data-validation-valid",
result.isValid.toString(),
);
button.setAttribute(
"data-validation-error",
result.error || "",
);
}
}}
data-testid="validate-button"
>
Validate
</button>
</div>
);
};
render(<TestComponent />);
const validateButton = screen.getByTestId("validate-button");
fireEvent.click(validateButton);
expect(validateButton.getAttribute("data-validation-valid")).toBe("true");
expect(validateButton.getAttribute("data-validation-error")).toBe("");
});
it("should return valid for valid JSON in JSON mode", () => {
renderFormWithRef();
const validateButton = screen.getByTestId("validate-button");
fireEvent.click(validateButton);
expect(validateButton.getAttribute("data-validation-valid")).toBe("true");
expect(validateButton.getAttribute("data-validation-error")).toBe("");
});
it("should return invalid for malformed JSON in JSON mode", async () => {
renderFormWithRef();
// Enter invalid JSON
const textarea = screen.getByRole("textbox");
fireEvent.change(textarea, { target: { value: '{ "invalid": json }' } });
// Wait a bit for any debounced updates
await waitFor(() => {
const validateButton = screen.getByTestId("validate-button");
fireEvent.click(validateButton);
expect(validateButton.getAttribute("data-validation-valid")).toBe(
"false",
);
expect(validateButton.getAttribute("data-validation-error")).toContain(
"JSON",
);
});
});
it("should return valid for empty JSON in JSON mode", () => {
renderFormWithRef();
// Clear the textarea
const textarea = screen.getByRole("textbox");
fireEvent.change(textarea, { target: { value: "" } });
const validateButton = screen.getByTestId("validate-button");
fireEvent.click(validateButton);
expect(validateButton.getAttribute("data-validation-valid")).toBe("true");
expect(validateButton.getAttribute("data-validation-error")).toBe("");
});
it("should set error state when validation fails", async () => {
renderFormWithRef();
// Enter invalid JSON
const textarea = screen.getByRole("textbox");
fireEvent.change(textarea, {
target: { value: '{ "trailing": "comma", }' },
});
// Trigger validation
const validateButton = screen.getByTestId("validate-button");
fireEvent.click(validateButton);
// Check that validation result shows error
expect(validateButton.getAttribute("data-validation-valid")).toBe(
"false",
);
expect(validateButton.getAttribute("data-validation-error")).toContain(
"JSON",
);
});
});
describe("forwardRef functionality", () => {
it("should expose validateJson method through ref", () => {
const TestComponent = () => {
const formRef = useRef<DynamicJsonFormRef>(null);
return (
<div>
<DynamicJsonForm
ref={formRef}
schema={{
type: "object",
properties: {
test: { type: "string" },
},
}}
value={{ test: "value" }}
onChange={jest.fn()}
/>
<button
onClick={() => {
const hasValidateMethod =
typeof formRef.current?.validateJson === "function";
const button = document.querySelector(
'[data-testid="ref-test-button"]',
) as HTMLElement;
if (button) {
button.setAttribute(
"data-has-validate-method",
hasValidateMethod.toString(),
);
}
}}
data-testid="ref-test-button"
>
Test Ref
</button>
</div>
);
};
render(<TestComponent />);
const testButton = screen.getByTestId("ref-test-button");
fireEvent.click(testButton);
expect(testButton.getAttribute("data-has-validate-method")).toBe("true");
});
});
});

View file

@ -0,0 +1,203 @@
import { render, screen, fireEvent, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, beforeEach, jest } from "@jest/globals";
import ListPane from "../ListPane";
describe("ListPane", () => {
const mockItems = [
{ id: 1, name: "Tool 1", description: "First tool" },
{ id: 2, name: "Tool 2", description: "Second tool" },
{ id: 3, name: "Another Tool", description: "Third tool" },
];
const defaultProps = {
items: mockItems,
listItems: jest.fn(),
clearItems: jest.fn(),
setSelectedItem: jest.fn(),
renderItem: (item: (typeof mockItems)[0]) => <div>{item.name}</div>,
title: "List tools",
buttonText: "Load Tools",
};
const renderListPane = (props = {}) => {
return render(<ListPane {...defaultProps} {...props} />);
};
beforeEach(() => {
jest.clearAllMocks();
});
describe("Rendering", () => {
it("should render with title and button", () => {
renderListPane();
expect(screen.getByText("List tools")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Load Tools" }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
});
it("should render items when provided", () => {
renderListPane();
expect(screen.getByText("Tool 1")).toBeInTheDocument();
expect(screen.getByText("Tool 2")).toBeInTheDocument();
expect(screen.getByText("Another Tool")).toBeInTheDocument();
});
it("should render empty state when no items", () => {
renderListPane({ items: [] });
expect(screen.queryByText("Tool 1")).not.toBeInTheDocument();
expect(screen.queryByText("Tool 2")).not.toBeInTheDocument();
});
it("should render custom item content", () => {
const customRenderItem = (item: (typeof mockItems)[0]) => (
<div>
<span>{item.name}</span>
<small>{item.description}</small>
</div>
);
renderListPane({ renderItem: customRenderItem });
expect(screen.getByText("Tool 1")).toBeInTheDocument();
expect(screen.getByText("First tool")).toBeInTheDocument();
});
});
describe("Search Functionality", () => {
it("should show search icon initially", () => {
renderListPane();
const searchButton = screen.getByRole("button", { name: "Search" });
expect(searchButton).toBeInTheDocument();
expect(searchButton.querySelector("svg")).toBeInTheDocument();
});
it("should expand search input when search icon is clicked", async () => {
renderListPane();
const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});
const searchInput = screen.getByPlaceholderText("Search...");
expect(searchInput).toBeInTheDocument();
// Wait for the setTimeout to complete and focus to be set
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
expect(searchInput).toHaveFocus();
});
it("should filter items based on search query", async () => {
renderListPane();
const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});
const searchInput = screen.getByPlaceholderText("Search...");
await act(async () => {
fireEvent.change(searchInput, { target: { value: "Tool" } });
});
expect(screen.getByText("Tool 1")).toBeInTheDocument();
expect(screen.getByText("Tool 2")).toBeInTheDocument();
expect(screen.getByText("Another Tool")).toBeInTheDocument();
await act(async () => {
fireEvent.change(searchInput, { target: { value: "Another" } });
});
expect(screen.queryByText("Tool 1")).not.toBeInTheDocument();
expect(screen.queryByText("Tool 2")).not.toBeInTheDocument();
expect(screen.getByText("Another Tool")).toBeInTheDocument();
});
it("should show 'No items found of matching \"NonExistent\"' when search has no results", async () => {
renderListPane();
const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});
const searchInput = screen.getByPlaceholderText("Search...");
await act(async () => {
fireEvent.change(searchInput, { target: { value: "NonExistent" } });
});
expect(
screen.getByText('No items found matching "NonExistent"'),
).toBeInTheDocument();
expect(screen.queryByText("Tool 1")).not.toBeInTheDocument();
});
it("should collapse search when input is empty and loses focus", async () => {
renderListPane();
const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});
const searchInput = screen.getByPlaceholderText("Search...");
await act(async () => {
fireEvent.change(searchInput, { target: { value: "test" } });
fireEvent.change(searchInput, { target: { value: "" } });
fireEvent.blur(searchInput);
});
const searchButtonAfterCollapse = screen.getByRole("button", {
name: "Search",
});
expect(searchButtonAfterCollapse).toBeInTheDocument();
expect(searchButtonAfterCollapse).not.toHaveClass("opacity-0");
});
it("should keep search expanded when input has content and loses focus", async () => {
renderListPane();
const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});
const searchInput = screen.getByPlaceholderText("Search...");
await act(async () => {
fireEvent.change(searchInput, { target: { value: "test" } });
fireEvent.blur(searchInput);
});
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
});
it("should search through all item properties (description)", async () => {
renderListPane();
const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});
const searchInput = screen.getByPlaceholderText("Search...");
await act(async () => {
fireEvent.change(searchInput, { target: { value: "First tool" } });
});
expect(screen.getByText("Tool 1")).toBeInTheDocument();
expect(screen.queryByText("Tool 2")).not.toBeInTheDocument();
});
});
});

View file

@ -44,12 +44,14 @@ describe("Sidebar", () => {
setSseUrl: jest.fn(),
oauthClientId: "",
setOauthClientId: jest.fn(),
oauthClientSecret: "",
setOauthClientSecret: jest.fn(),
oauthScope: "",
setOauthScope: jest.fn(),
env: {},
setEnv: jest.fn(),
bearerToken: "",
setBearerToken: jest.fn(),
customHeaders: [],
setCustomHeaders: jest.fn(),
onConnect: jest.fn(),
onDisconnect: jest.fn(),
stdErrNotifications: [],
@ -59,6 +61,8 @@ describe("Sidebar", () => {
loggingSupported: true,
config: DEFAULT_INSPECTOR_CONFIG,
setConfig: jest.fn(),
connectionType: "proxy" as const,
setConnectionType: jest.fn(),
};
const renderSidebar = (props = {}) => {
@ -446,6 +450,11 @@ describe("Sidebar", () => {
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
expect(mockToast).toHaveBeenCalledWith({
title: "Config entry copied",
description:
"Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name.",
});
});
it("should copy servers file configuration to clipboard for STDIO transport", async () => {
@ -504,6 +513,11 @@ describe("Sidebar", () => {
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
expect(mockToast).toHaveBeenCalledWith({
title: "Config entry copied",
description:
"SSE URL has been copied. Use this URL directly in your MCP Client.",
});
});
it("should copy servers file configuration to clipboard for SSE transport", async () => {
@ -554,6 +568,11 @@ describe("Sidebar", () => {
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
expect(mockToast).toHaveBeenCalledWith({
title: "Config entry copied",
description:
"Streamable HTTP URL has been copied. Use this URL directly in your MCP Client.",
});
});
it("should copy servers file configuration to clipboard for streamable-http transport", async () => {
@ -619,117 +638,179 @@ describe("Sidebar", () => {
fireEvent.click(button);
};
it("should update bearer token", () => {
const setBearerToken = jest.fn();
it("should update bearer token via custom headers", async () => {
const setCustomHeaders = jest.fn();
renderSidebar({
bearerToken: "",
setBearerToken,
customHeaders: [],
setCustomHeaders,
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
const tokenInput = screen.getByTestId("bearer-token-input");
fireEvent.change(tokenInput, { target: { value: "new_token" } });
// Add a new header
const addButton = screen.getByTestId("add-header-button");
fireEvent.click(addButton);
expect(setBearerToken).toHaveBeenCalledWith("new_token");
// Verify that setCustomHeaders was called to add an empty header
expect(setCustomHeaders).toHaveBeenCalledWith([
{
name: "",
value: "",
enabled: true,
},
]);
});
it("should update header name", () => {
const setHeaderName = jest.fn();
it("should update header name via custom headers", () => {
const setCustomHeaders = jest.fn();
renderSidebar({
headerName: "Authorization",
setHeaderName,
customHeaders: [
{
name: "Authorization",
value: "Bearer token123",
enabled: true,
},
],
setCustomHeaders,
transportType: "sse",
});
openAuthSection();
const headerInput = screen.getByTestId("header-input");
fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } });
const headerNameInput = screen.getByTestId("header-name-input-0");
fireEvent.change(headerNameInput, { target: { value: "X-Custom-Auth" } });
expect(setHeaderName).toHaveBeenCalledWith("X-Custom-Auth");
expect(setCustomHeaders).toHaveBeenCalledWith([
{
name: "X-Custom-Auth",
value: "Bearer token123",
enabled: true,
},
]);
});
it("should clear bearer token", () => {
const setBearerToken = jest.fn();
it("should clear bearer token via custom headers", () => {
const setCustomHeaders = jest.fn();
renderSidebar({
bearerToken: "existing_token",
setBearerToken,
customHeaders: [
{
name: "Authorization",
value: "Bearer existing_token",
enabled: true,
},
],
setCustomHeaders,
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
const tokenInput = screen.getByTestId("bearer-token-input");
fireEvent.change(tokenInput, { target: { value: "" } });
const headerValueInput = screen.getByTestId("header-value-input-0");
fireEvent.change(headerValueInput, { target: { value: "" } });
expect(setBearerToken).toHaveBeenCalledWith("");
expect(setCustomHeaders).toHaveBeenCalledWith([
{
name: "Authorization",
value: "",
enabled: true,
},
]);
});
it("should properly render bearer token input", () => {
it("should properly render header value input as password field", () => {
const { rerender } = renderSidebar({
bearerToken: "existing_token",
customHeaders: [
{
name: "Authorization",
value: "Bearer existing_token",
enabled: true,
},
],
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
// Token input should be a password field
const tokenInput = screen.getByTestId("bearer-token-input");
const tokenInput = screen.getByTestId("header-value-input-0");
expect(tokenInput).toHaveProperty("type", "password");
// Update the token
fireEvent.change(tokenInput, { target: { value: "new_token" } });
fireEvent.change(tokenInput, { target: { value: "Bearer new_token" } });
// Rerender with updated token
rerender(
<TooltipProvider>
<Sidebar
{...defaultProps}
bearerToken="new_token"
customHeaders={[
{
name: "Authorization",
value: "Bearer new_token",
enabled: true,
},
]}
transportType="sse"
/>
</TooltipProvider>,
);
// Token input should still exist after update
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
expect(screen.getByTestId("header-value-input-0")).toBeInTheDocument();
});
it("should maintain token visibility state after update", () => {
const { rerender } = renderSidebar({
bearerToken: "existing_token",
customHeaders: [
{
name: "Authorization",
value: "Bearer existing_token",
enabled: true,
},
],
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
// Token input should be a password field
const tokenInput = screen.getByTestId("bearer-token-input");
const tokenInput = screen.getByTestId("header-value-input-0");
expect(tokenInput).toHaveProperty("type", "password");
// Update the token
fireEvent.change(tokenInput, { target: { value: "new_token" } });
fireEvent.change(tokenInput, { target: { value: "Bearer new_token" } });
// Rerender with updated token
rerender(
<TooltipProvider>
<Sidebar
{...defaultProps}
bearerToken="new_token"
customHeaders={[
{
name: "Authorization",
value: "Bearer new_token",
enabled: true,
},
]}
transportType="sse"
/>
</TooltipProvider>,
);
// Token input should still exist after update
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
expect(screen.getByTestId("header-value-input-0")).toBeInTheDocument();
});
it("should maintain header name when toggling auth section", () => {
renderSidebar({
headerName: "X-API-Key",
customHeaders: [
{
name: "X-API-Key",
value: "api-key-123",
enabled: true,
},
],
transportType: "sse",
});
@ -737,7 +818,7 @@ describe("Sidebar", () => {
openAuthSection();
// Verify header name is displayed
const headerInput = screen.getByTestId("header-input");
const headerInput = screen.getByTestId("header-name-input-0");
expect(headerInput).toHaveValue("X-API-Key");
// Close auth section
@ -748,19 +829,66 @@ describe("Sidebar", () => {
fireEvent.click(authButton);
// Verify header name is still preserved
expect(screen.getByTestId("header-input")).toHaveValue("X-API-Key");
expect(screen.getByTestId("header-name-input-0")).toHaveValue(
"X-API-Key",
);
});
it("should display default header name when not specified", () => {
it("should display placeholder for header name when no headers exist", () => {
renderSidebar({
headerName: undefined,
customHeaders: [],
transportType: "sse",
});
openAuthSection();
const headerInput = screen.getByTestId("header-input");
expect(headerInput).toHaveAttribute("placeholder", "Authorization");
// Verify that the "No custom headers configured" message is shown
expect(
screen.getByText("No custom headers configured"),
).toBeInTheDocument();
expect(
screen.getByText('Click "Add" to get started'),
).toBeInTheDocument();
// Verify the Add button is present
const addButton = screen.getByTestId("add-header-button");
expect(addButton).toBeInTheDocument();
});
it("should allow editing existing headers", () => {
const setCustomHeaders = jest.fn();
renderSidebar({
customHeaders: [
{
name: "Authorization",
value: "Bearer token123",
enabled: true,
},
],
setCustomHeaders,
transportType: "sse",
});
openAuthSection();
// Verify header inputs are rendered
const headerNameInput = screen.getByTestId("header-name-input-0");
const headerValueInput = screen.getByTestId("header-value-input-0");
expect(headerNameInput).toHaveValue("Authorization");
expect(headerValueInput).toHaveValue("Bearer token123");
expect(headerNameInput).toHaveAttribute("placeholder", "Header Name");
expect(headerValueInput).toHaveAttribute("placeholder", "Header Value");
// Update header name
fireEvent.change(headerNameInput, { target: { value: "X-API-Key" } });
expect(setCustomHeaders).toHaveBeenCalledWith([
{
name: "X-API-Key",
value: "Bearer token123",
enabled: true,
},
]);
});
});

Some files were not shown because too many files have changed in this diff Show more