Merge branch 'main' into accurate-cli-version

This commit is contained in:
Ola Hungerford 2025-09-22 20:46:14 -07:00 • committed by GitHub
commit 32ea112051
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 3542 additions and 561 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.16.5",
"version": "0.16.8",
"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.3",
"@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",
@ -48,7 +49,6 @@
"react-simple-code-editor": "^0.14.1",
"serve-handler": "^6.1.6",
"tailwind-merge": "^2.5.3",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.25.76"
},
"devDependencies": {
@ -70,8 +70,10 @@
"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",
"ts-jest": "^29.4.0",
"typescript": "^5.5.3",
"typescript-eslint": "^8.38.0",

View file

@ -74,6 +74,10 @@ import ElicitationTab, {
PendingElicitationRequest,
ElicitationResponse,
} from "./components/ElicitationTab";
import {
CustomHeaders,
migrateFromLegacyAuth,
} from "./lib/types/customHeaders";
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
@ -103,6 +107,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 +139,39 @@ const App = () => {
return localStorage.getItem("lastOauthScope") || "";
});
// 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 Authorization: Bearer as the most common case
return [
{
name: "Authorization",
value: "Bearer ",
enabled: true,
},
];
});
const [pendingSampleRequests, setPendingSampleRequests] = useState<
Array<
PendingRequest & {
@ -200,6 +245,7 @@ const App = () => {
serverCapabilities,
mcpClient,
requestHistory,
clearRequestHistory,
makeRequest,
sendNotification,
handleCompletion,
@ -212,11 +258,11 @@ const App = () => {
args,
sseUrl,
env,
bearerToken,
headerName,
customHeaders,
oauthClientId,
oauthScope,
config,
connectionType,
onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]);
},
@ -302,13 +348,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]);
@ -736,6 +811,10 @@ const App = () => {
await sendNotification({ method: "notifications/roots/list_changed" });
};
const handleClearNotifications = () => {
setNotifications([]);
};
const sendLogLevelRequest = async (level: LoggingLevel) => {
await sendMCPRequest(
{
@ -805,10 +884,8 @@ const App = () => {
setEnv={setEnv}
config={config}
setConfig={setConfig}
bearerToken={bearerToken}
setBearerToken={setBearerToken}
headerName={headerName}
setHeaderName={setHeaderName}
customHeaders={customHeaders}
setCustomHeaders={setCustomHeaders}
oauthClientId={oauthClientId}
setOauthClientId={setOauthClientId}
oauthScope={oauthScope}
@ -818,6 +895,8 @@ const App = () => {
logLevel={logLevel}
sendLogLevelRequest={sendLogLevelRequest}
loggingSupported={!!serverCapabilities?.logging || false}
connectionType={connectionType}
setConnectionType={setConnectionType}
/>
<div
onMouseDown={handleSidebarDragStart}
@ -1102,6 +1181,8 @@ const App = () => {
<HistoryAndNotifications
requestHistory={requestHistory}
serverNotifications={notifications}
onClearHistory={clearRequestHistory}
onClearNotifications={handleClearNotifications}
/>
</div>
</div>

View file

@ -49,6 +49,7 @@ jest.mock("../lib/hooks/useConnection", () => ({
serverCapabilities: null,
mcpClient: null,
requestHistory: [],
clearRequestHistory: jest.fn(),
makeRequest: jest.fn(),
sendNotification: jest.fn(),
handleCompletion: jest.fn(),

View file

@ -42,6 +42,7 @@ const disconnectedConnectionState = {
serverCapabilities: null,
mcpClient: null,
requestHistory: [],
clearRequestHistory: jest.fn(),
makeRequest: jest.fn(),
sendNotification: jest.fn(),
handleCompletion: jest.fn(),

View file

@ -187,7 +187,7 @@ const AuthDebugger = ({
JSON.stringify(currentState),
);
// Open the authorization URL automatically
window.location.href = currentState.authorizationUrl;
window.location.href = currentState.authorizationUrl.toString();
break;
}
}

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;

View file

@ -1,13 +1,18 @@
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
import { useState } from "react";
import JsonView from "./JsonView";
import { Button } from "@/components/ui/button";
const HistoryAndNotifications = ({
requestHistory,
serverNotifications,
onClearHistory,
onClearNotifications,
}: {
requestHistory: Array<{ request: string; response?: string }>;
serverNotifications: ServerNotification[];
onClearHistory?: () => void;
onClearNotifications?: () => void;
}) => {
const [expandedRequests, setExpandedRequests] = useState<{
[key: number]: boolean;
@ -27,7 +32,17 @@ const HistoryAndNotifications = ({
return (
<div className="bg-card overflow-hidden flex h-full">
<div className="flex-1 overflow-y-auto p-4 border-r">
<h2 className="text-lg font-semibold mb-4">History</h2>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">History</h2>
<Button
variant="outline"
size="sm"
onClick={onClearHistory}
disabled={requestHistory.length === 0}
>
Clear
</Button>
</div>
{requestHistory.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
No history yet
@ -93,7 +108,17 @@ const HistoryAndNotifications = ({
)}
</div>
<div className="flex-1 overflow-y-auto p-4">
<h2 className="text-lg font-semibold mb-4">Server Notifications</h2>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Server Notifications</h2>
<Button
variant="outline"
size="sm"
onClick={onClearNotifications}
disabled={serverNotifications.length === 0}
>
Clear
</Button>
</div>
{serverNotifications.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
No notifications yet

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

@ -240,7 +240,7 @@ export const OAuthFlowProgress = ({
<p className="font-medium mb-2 text-sm">Authorization URL:</p>
<div className="flex items-center gap-2">
<p className="text-xs break-all">
{authState.authorizationUrl}
{String(authState.authorizationUrl)}
</p>
<button
onClick={() => {

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,10 +53,9 @@ 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;
oauthScope: string;
@ -66,6 +67,8 @@ interface SidebarProps {
loggingSupported: boolean;
config: InspectorConfig;
setConfig: (config: InspectorConfig) => void;
connectionType: "direct" | "proxy";
setConnectionType: (type: "direct" | "proxy") => void;
}
const Sidebar = ({
@ -80,10 +83,8 @@ const Sidebar = ({
setSseUrl,
env,
setEnv,
bearerToken,
setBearerToken,
headerName,
setHeaderName,
customHeaders,
setCustomHeaders,
oauthClientId,
setOauthClientId,
oauthScope,
@ -95,6 +96,8 @@ const Sidebar = ({
loggingSupported,
config,
setConfig,
connectionType,
setConnectionType,
}: SidebarProps) => {
const [theme, setTheme] = useTheme();
const [showEnvVars, setShowEnvVars] = useState(false);
@ -105,6 +108,8 @@ const Sidebar = ({
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 +180,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 +319,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 +533,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

View file

@ -17,11 +17,20 @@ import {
ListToolsResult,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { Loader2, Send, ChevronDown, ChevronUp } from "lucide-react";
import {
Loader2,
Send,
ChevronDown,
ChevronUp,
Copy,
CheckCheck,
} from "lucide-react";
import { useEffect, useState } 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 } =>
@ -55,6 +64,8 @@ const ToolsTab = ({
const [isToolRunning, setIsToolRunning] = useState(false);
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
const [isMetaExpanded, setIsMetaExpanded] = useState(false);
const { toast } = useToast();
const { copied, setCopied } = useCopy();
useEffect(() => {
const params = Object.entries(
@ -291,29 +302,54 @@ 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 () => {
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>
<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

@ -482,7 +482,9 @@ describe("AuthDebugger", () => {
await waitFor(() => {
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
authorizationUrl: expect.stringContaining("scope="),
authorizationUrl: expect.objectContaining({
href: "https://oauth.example.com/authorize?scope=read+write",
}),
}),
);
});
@ -496,7 +498,9 @@ describe("AuthDebugger", () => {
await waitFor(() => {
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
authorizationUrl: expect.stringContaining("scope="),
authorizationUrl: expect.objectContaining({
href: "https://oauth.example.com/authorize?scope=read+write",
}),
}),
);
});

View file

@ -1,4 +1,5 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, within } from "@testing-library/react";
import { useState } from "react";
import { describe, it, expect, jest } from "@jest/globals";
import HistoryAndNotifications from "../HistoryAndNotifications";
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
@ -223,4 +224,66 @@ describe("HistoryAndNotifications", () => {
expect(screen.getByText("No history yet")).toBeTruthy();
expect(screen.getByText("No notifications yet")).toBeTruthy();
});
it("clears request history when Clear is clicked", () => {
const Wrapper = () => {
const [history, setHistory] = useState(mockRequestHistory);
return (
<HistoryAndNotifications
requestHistory={history}
serverNotifications={[]}
onClearHistory={() => setHistory([])}
/>
);
};
render(<Wrapper />);
// Verify items are present initially
expect(screen.getByText("2. test/method2")).toBeTruthy();
expect(screen.getByText("1. test/method1")).toBeTruthy();
// Click Clear in History header (scoped by the History heading's container)
const historyHeader = screen.getByText("History");
const historyHeaderContainer = historyHeader.parentElement as HTMLElement;
const historyClearButton = within(historyHeaderContainer).getByRole(
"button",
{ name: "Clear" },
);
fireEvent.click(historyClearButton);
// History should now be empty
expect(screen.getByText("No history yet")).toBeTruthy();
});
it("clears server notifications when Clear is clicked", () => {
const Wrapper = () => {
const [notifications, setNotifications] =
useState<ServerNotification[]>(mockNotifications);
return (
<HistoryAndNotifications
requestHistory={[]}
serverNotifications={notifications}
onClearNotifications={() => setNotifications([])}
/>
);
};
render(<Wrapper />);
// Verify items are present initially
expect(screen.getByText("2. notifications/progress")).toBeTruthy();
expect(screen.getByText("1. notifications/message")).toBeTruthy();
// Click Clear in Server Notifications header (scoped by its heading's container)
const notifHeader = screen.getByText("Server Notifications");
const notifHeaderContainer = notifHeader.parentElement as HTMLElement;
const notifClearButton = within(notifHeaderContainer).getByRole("button", {
name: "Clear",
});
fireEvent.click(notifClearButton);
// Notifications should now be empty
expect(screen.getByText("No notifications yet")).toBeTruthy();
});
});

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

@ -48,8 +48,8 @@ describe("Sidebar", () => {
setOauthScope: jest.fn(),
env: {},
setEnv: jest.fn(),
bearerToken: "",
setBearerToken: jest.fn(),
customHeaders: [],
setCustomHeaders: jest.fn(),
onConnect: jest.fn(),
onDisconnect: jest.fn(),
stdErrNotifications: [],
@ -59,6 +59,8 @@ describe("Sidebar", () => {
loggingSupported: true,
config: DEFAULT_INSPECTOR_CONFIG,
setConfig: jest.fn(),
connectionType: "proxy" as const,
setConnectionType: jest.fn(),
};
const renderSidebar = (props = {}) => {
@ -446,6 +448,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 +511,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 +566,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 +636,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 +816,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 +827,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,
},
]);
});
});

View file

@ -0,0 +1,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View file

@ -34,7 +34,7 @@ export interface AuthDebuggerState {
authServerUrl: URL | null;
oauthMetadata: OAuthMetadata | null;
oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null;
authorizationUrl: string | null;
authorizationUrl: URL | null;
authorizationCode: string;
latestError: Error | null;
statusMessage: StatusMessage | null;

View file

@ -117,9 +117,14 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
return window.location.origin + "/oauth/callback";
}
get debugRedirectUrl() {
return window.location.origin + "/oauth/callback/debug";
}
get clientMetadata(): OAuthClientMetadata {
// Register both redirect URIs to support both normal and debug flows
return {
redirect_uris: [this.redirectUrl],
redirect_uris: [this.redirectUrl, this.debugRedirectUrl],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
@ -223,11 +228,13 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
}
}
// Overrides debug URL and allows saving server OAuth metadata to
// Overrides redirect URL to use the debug endpoint and allows saving server OAuth metadata to
// display in debug UI.
export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider {
get redirectUrl(): string {
return `${window.location.origin}/oauth/callback/debug`;
// We can use the debug redirect URL here because it was already registered
// in the parent class's clientMetadata along with the normal redirect URL
return this.debugRedirectUrl;
}
saveServerMetadata(metadata: OAuthMetadata) {

View file

@ -13,10 +13,14 @@ import {
} from "@modelcontextprotocol/sdk/types.js";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { discoverScopes } from "../../auth";
import { CustomHeaders } from "../../types/customHeaders";
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({
json: () => Promise.resolve({ status: "ok" }),
headers: {
get: jest.fn().mockReturnValue(null),
},
});
// Mock the SDK dependencies
@ -573,9 +577,10 @@ describe("useConnection", () => {
mockStreamableHTTPTransport.options = undefined;
});
test("sends X-MCP-Proxy-Auth header when proxy auth token is configured", async () => {
test("sends X-MCP-Proxy-Auth header when proxy auth token is configured for proxy connectionType", async () => {
const propsWithProxyAuth = {
...defaultProps,
connectionType: "proxy" as const,
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
@ -625,6 +630,56 @@ describe("useConnection", () => {
).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token");
});
test("does NOT send X-MCP-Proxy-Auth header when proxy auth token is configured for direct connectionType", async () => {
const propsWithProxyAuth = {
...defaultProps,
connectionType: "direct" as const,
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
},
};
const { result } = renderHook(() => useConnection(propsWithProxyAuth));
await act(async () => {
await result.current.connect();
});
// Check that the transport was created with the correct headers
expect(mockSSETransport.options).toBeDefined();
expect(mockSSETransport.options?.requestInit).toBeDefined();
// Verify that X-MCP-Proxy-Auth header is NOT present for direct connections
expect(mockSSETransport.options?.requestInit?.headers).not.toHaveProperty(
"X-MCP-Proxy-Auth",
);
expect(mockSSETransport?.options?.fetch).toBeDefined();
// Verify the fetch function does NOT include the proxy auth header
const mockFetch = mockSSETransport.options?.fetch;
const testUrl = "http://test.com";
await mockFetch?.(testUrl, {
headers: {
Accept: "text/event-stream",
},
cache: "no-store",
mode: "cors",
signal: new AbortController().signal,
redirect: "follow",
credentials: "include",
});
expect(global.fetch).toHaveBeenCalledTimes(1);
expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe(testUrl);
expect(
(global.fetch as jest.Mock).mock.calls[0][1].headers,
).not.toHaveProperty("X-MCP-Proxy-Auth");
});
test("does NOT send Authorization header for proxy auth", async () => {
const propsWithProxyAuth = {
...defaultProps,
@ -648,9 +703,17 @@ describe("useConnection", () => {
});
test("preserves server Authorization header when proxy auth is configured", async () => {
const customHeaders: CustomHeaders = [
{
name: "Authorization",
value: "Bearer server-auth-token",
enabled: true,
},
];
const propsWithBothAuth = {
...defaultProps,
bearerToken: "server-auth-token",
customHeaders,
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
@ -740,6 +803,190 @@ describe("useConnection", () => {
});
});
describe("Custom Headers", () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset the mock transport objects
mockSSETransport.url = undefined;
mockSSETransport.options = undefined;
mockStreamableHTTPTransport.url = undefined;
mockStreamableHTTPTransport.options = undefined;
});
test("sends multiple custom headers correctly", async () => {
const customHeaders: CustomHeaders = [
{ name: "Authorization", value: "Bearer token123", enabled: true },
{ name: "X-Tenant-ID", value: "acme-inc", enabled: true },
{ name: "X-Environment", value: "staging", enabled: true },
];
const propsWithCustomHeaders = {
...defaultProps,
customHeaders,
};
const { result } = renderHook(() =>
useConnection(propsWithCustomHeaders),
);
await act(async () => {
await result.current.connect();
});
// Check that the transport was created with the correct headers
expect(mockSSETransport.options).toBeDefined();
expect(mockSSETransport.options?.requestInit?.headers).toBeDefined();
const headers = mockSSETransport.options?.requestInit?.headers;
expect(headers).toHaveProperty("Authorization", "Bearer token123");
expect(headers).toHaveProperty("X-Tenant-ID", "acme-inc");
expect(headers).toHaveProperty("X-Environment", "staging");
expect(headers).toHaveProperty(
"x-custom-auth-headers",
JSON.stringify(["X-Tenant-ID", "X-Environment"]),
);
});
test("ignores disabled custom headers", async () => {
const customHeaders: CustomHeaders = [
{ name: "Authorization", value: "Bearer token123", enabled: true },
{ name: "X-Disabled", value: "should-not-appear", enabled: false },
{ name: "X-Enabled", value: "should-appear", enabled: true },
];
const propsWithCustomHeaders = {
...defaultProps,
customHeaders,
};
const { result } = renderHook(() =>
useConnection(propsWithCustomHeaders),
);
await act(async () => {
await result.current.connect();
});
const headers = mockSSETransport.options?.requestInit?.headers;
expect(headers).toHaveProperty("Authorization", "Bearer token123");
expect(headers).toHaveProperty("X-Enabled", "should-appear");
expect(headers).not.toHaveProperty("X-Disabled");
});
test("handles migrated legacy auth via custom headers", async () => {
// Simulate what App.tsx would do - migrate legacy auth to custom headers
const customHeaders: CustomHeaders = [
{ name: "X-Custom-Auth", value: "legacy-token", enabled: true },
];
const propsWithMigratedAuth = {
...defaultProps,
customHeaders,
};
const { result } = renderHook(() => useConnection(propsWithMigratedAuth));
await act(async () => {
await result.current.connect();
});
const headers = mockSSETransport.options?.requestInit?.headers;
expect(headers).toHaveProperty("X-Custom-Auth", "legacy-token");
expect(headers).toHaveProperty(
"x-custom-auth-headers",
JSON.stringify(["X-Custom-Auth"]),
);
});
test("uses OAuth token when no custom headers or legacy auth provided", async () => {
const propsWithoutAuth = {
...defaultProps,
};
const { result } = renderHook(() => useConnection(propsWithoutAuth));
await act(async () => {
await result.current.connect();
});
const headers = mockSSETransport.options?.requestInit?.headers;
expect(headers).toHaveProperty("Authorization", "Bearer mock-token");
});
test("prioritizes custom headers over legacy auth", async () => {
const customHeaders: CustomHeaders = [
{ name: "Authorization", value: "Bearer custom-token", enabled: true },
];
const propsWithBothAuth = {
...defaultProps,
customHeaders,
bearerToken: "legacy-token",
headerName: "Authorization",
};
const { result } = renderHook(() => useConnection(propsWithBothAuth));
await act(async () => {
await result.current.connect();
});
const headers = mockSSETransport.options?.requestInit?.headers;
expect(headers).toHaveProperty("Authorization", "Bearer custom-token");
});
});
describe("Connection URL Verification", () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset the mock transport objects
mockSSETransport.url = undefined;
mockSSETransport.options = undefined;
mockStreamableHTTPTransport.url = undefined;
mockStreamableHTTPTransport.options = undefined;
});
test("uses server URL directly when connectionType is 'direct'", async () => {
const directProps = {
...defaultProps,
connectionType: "direct" as const,
};
const { result } = renderHook(() => useConnection(directProps));
await act(async () => {
await result.current.connect();
});
// Verify the transport was created with the direct server URL
expect(mockSSETransport.url).toBeDefined();
expect(mockSSETransport.url?.toString()).toBe("http://localhost:8080/");
});
test("uses proxy server URL when connectionType is 'proxy'", async () => {
const proxyProps = {
...defaultProps,
connectionType: "proxy" as const,
};
const { result } = renderHook(() => useConnection(proxyProps));
await act(async () => {
await result.current.connect();
});
// Verify the transport was created with a proxy server URL
expect(mockSSETransport.url).toBeDefined();
expect(mockSSETransport.url?.pathname).toBe("/sse");
expect(mockSSETransport.url?.searchParams.get("url")).toBe(
"http://localhost:8080",
);
expect(mockSSETransport.url?.searchParams.get("transportType")).toBe(
"sse",
);
});
});
describe("OAuth Error Handling with Scope Discovery", () => {
beforeEach(() => {
jest.clearAllMocks();
@ -877,4 +1124,129 @@ describe("useConnection", () => {
});
});
});
describe("MCP_PROXY_FULL_ADDRESS Configuration", () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset the mock transport objects
mockSSETransport.url = undefined;
mockSSETransport.options = undefined;
mockStreamableHTTPTransport.url = undefined;
mockStreamableHTTPTransport.options = undefined;
});
test("sends proxyFullAddress query parameter for stdio transport when configured", async () => {
const propsWithProxyFullAddress = {
...defaultProps,
transportType: "stdio" as const,
command: "test-command",
args: "test-args",
env: {},
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_FULL_ADDRESS: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
value: "https://example.com/inspector/mcp_proxy",
},
},
};
const { result } = renderHook(() =>
useConnection(propsWithProxyFullAddress),
);
await act(async () => {
await result.current.connect();
});
// Check that the URL contains the proxyFullAddress parameter
expect(mockSSETransport.url?.searchParams.get("proxyFullAddress")).toBe(
"https://example.com/inspector/mcp_proxy",
);
});
test("sends proxyFullAddress query parameter for sse transport when configured", async () => {
const propsWithProxyFullAddress = {
...defaultProps,
transportType: "sse" as const,
sseUrl: "http://localhost:8080",
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_FULL_ADDRESS: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
value: "https://example.com/inspector/mcp_proxy",
},
},
};
const { result } = renderHook(() =>
useConnection(propsWithProxyFullAddress),
);
await act(async () => {
await result.current.connect();
});
// Check that the URL contains the proxyFullAddress parameter
expect(mockSSETransport.url?.searchParams.get("proxyFullAddress")).toBe(
"https://example.com/inspector/mcp_proxy",
);
});
test("does not send proxyFullAddress parameter when MCP_PROXY_FULL_ADDRESS is empty", async () => {
const propsWithEmptyProxy = {
...defaultProps,
transportType: "stdio" as const,
command: "test-command",
args: "test-args",
env: {},
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_FULL_ADDRESS: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
value: "",
},
},
};
const { result } = renderHook(() => useConnection(propsWithEmptyProxy));
await act(async () => {
await result.current.connect();
});
// Check that the URL does not contain the proxyFullAddress parameter
expect(
mockSSETransport.url?.searchParams.get("proxyFullAddress"),
).toBeNull();
});
test("does not send proxyFullAddress parameter for streamable-http transport", async () => {
const propsWithStreamableHttp = {
...defaultProps,
transportType: "streamable-http" as const,
sseUrl: "http://localhost:8080",
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_FULL_ADDRESS: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
value: "https://example.com/inspector/mcp_proxy",
},
},
};
const { result } = renderHook(() =>
useConnection(propsWithStreamableHttp),
);
await act(async () => {
await result.current.connect();
});
// Check that streamable-http transport doesn't get proxyFullAddress parameter
expect(
mockStreamableHTTPTransport.url?.searchParams.get("proxyFullAddress"),
).toBeNull();
});
});
});

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