Merge branch 'main' of https://github.com/modelcontextprotocol/inspector into json_view_copy_button
This commit is contained in:
commit
fde922130d
32 changed files with 4762 additions and 1097 deletions
|
|
@ -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,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 empty array
|
||||
return [
|
||||
{
|
||||
name: "Authorization",
|
||||
value: "Bearer ",
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
||||
Array<
|
||||
PendingRequest & {
|
||||
|
|
@ -200,6 +247,7 @@ const App = () => {
|
|||
serverCapabilities,
|
||||
mcpClient,
|
||||
requestHistory,
|
||||
clearRequestHistory,
|
||||
makeRequest,
|
||||
sendNotification,
|
||||
handleCompletion,
|
||||
|
|
@ -212,11 +260,11 @@ const App = () => {
|
|||
args,
|
||||
sseUrl,
|
||||
env,
|
||||
bearerToken,
|
||||
headerName,
|
||||
customHeaders,
|
||||
oauthClientId,
|
||||
oauthScope,
|
||||
config,
|
||||
connectionType,
|
||||
onNotification: (notification) => {
|
||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||
},
|
||||
|
|
@ -302,13 +350,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]);
|
||||
|
|
@ -702,12 +779,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++,
|
||||
},
|
||||
|
|
@ -718,6 +801,8 @@ const App = () => {
|
|||
);
|
||||
|
||||
setToolResult(response);
|
||||
// Clear any validation errors since tool execution completed
|
||||
setErrors((prev) => ({ ...prev, tools: null }));
|
||||
} catch (e) {
|
||||
const toolResult: CompatibilityCallToolResult = {
|
||||
content: [
|
||||
|
|
@ -729,6 +814,8 @@ const App = () => {
|
|||
isError: true,
|
||||
};
|
||||
setToolResult(toolResult);
|
||||
// Clear validation errors - tool execution errors are shown in ToolResults
|
||||
setErrors((prev) => ({ ...prev, tools: null }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -736,6 +823,10 @@ const App = () => {
|
|||
await sendNotification({ method: "notifications/roots/list_changed" });
|
||||
};
|
||||
|
||||
const handleClearNotifications = () => {
|
||||
setNotifications([]);
|
||||
};
|
||||
|
||||
const sendLogLevelRequest = async (level: LoggingLevel) => {
|
||||
await sendMCPRequest(
|
||||
{
|
||||
|
|
@ -805,10 +896,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 +907,8 @@ const App = () => {
|
|||
logLevel={logLevel}
|
||||
sendLogLevelRequest={sendLogLevelRequest}
|
||||
loggingSupported={!!serverCapabilities?.logging || false}
|
||||
connectionType={connectionType}
|
||||
setConnectionType={setConnectionType}
|
||||
/>
|
||||
<div
|
||||
onMouseDown={handleSidebarDragStart}
|
||||
|
|
@ -1102,6 +1193,8 @@ const App = () => {
|
|||
<HistoryAndNotifications
|
||||
requestHistory={requestHistory}
|
||||
serverNotifications={notifications}
|
||||
onClearHistory={clearRequestHistory}
|
||||
onClearNotifications={handleClearNotifications}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ const disconnectedConnectionState = {
|
|||
serverCapabilities: null,
|
||||
mcpClient: null,
|
||||
requestHistory: [],
|
||||
clearRequestHistory: jest.fn(),
|
||||
makeRequest: jest.fn(),
|
||||
sendNotification: jest.fn(),
|
||||
handleCompletion: jest.fn(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
241
client/src/components/CustomHeaders.tsx
Normal file
241
client/src/components/CustomHeaders.tsx
Normal 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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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;
|
||||
|
|
@ -26,21 +27,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"
|
||||
|
|
|
|||
|
|
@ -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 "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default ListPane;
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
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 DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm";
|
||||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||
import {
|
||||
generateDefaultValue,
|
||||
|
|
@ -17,11 +17,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 +46,7 @@ const ToolsTab = ({
|
|||
setSelectedTool,
|
||||
toolResult,
|
||||
nextCursor,
|
||||
error,
|
||||
resourceContent,
|
||||
onReadResource,
|
||||
}: {
|
||||
|
|
@ -55,6 +66,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 +92,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 (
|
||||
|
|
@ -103,6 +133,13 @@ const ToolsTab = ({
|
|||
<div className="p-4">
|
||||
{selectedTool ? (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{selectedTool.description}
|
||||
</p>
|
||||
|
|
@ -148,21 +185,33 @@ const ToolsTab = ({
|
|||
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 +227,8 @@ const ToolsTab = ({
|
|||
...params,
|
||||
[key]: newValue,
|
||||
});
|
||||
// Check validation after a short delay to allow form to update
|
||||
setTimeout(checkValidationErrors, 100);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -188,19 +239,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 +287,8 @@ const ToolsTab = ({
|
|||
...params,
|
||||
[key]: newValue,
|
||||
});
|
||||
// Check validation after a short delay to allow form to update
|
||||
setTimeout(checkValidationErrors, 100);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -291,29 +367,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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
203
client/src/components/__tests__/ListPane.test.tsx
Normal file
203
client/src/components/__tests__/ListPane.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -637,4 +637,189 @@ describe("ToolsTab", () => {
|
|||
expect(screen.getByText(/version/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("JSON Validation Integration", () => {
|
||||
const toolWithJsonParams: Tool = {
|
||||
name: "jsonTool",
|
||||
description: "Tool with JSON parameters",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
required: ["config", "data"], // Make them required so they render as form fields
|
||||
properties: {
|
||||
config: {
|
||||
type: "object" as const,
|
||||
// No properties defined - this will force JSON mode
|
||||
},
|
||||
data: {
|
||||
type: "array" as const,
|
||||
// No items defined - this will force JSON mode
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("should prevent tool execution when JSON validation fails", async () => {
|
||||
const mockCallTool = jest.fn();
|
||||
renderToolsTab({
|
||||
tools: [toolWithJsonParams],
|
||||
selectedTool: toolWithJsonParams,
|
||||
callTool: mockCallTool,
|
||||
});
|
||||
|
||||
// Find JSON editor textareas (there should be at least 1 for JSON parameters)
|
||||
const textareas = screen.getAllByRole("textbox");
|
||||
expect(textareas.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Enter invalid JSON in the first textarea
|
||||
const configTextarea = textareas[0];
|
||||
fireEvent.change(configTextarea, {
|
||||
target: { value: '{ "invalid": json }' },
|
||||
});
|
||||
|
||||
// Try to run the tool
|
||||
const runButton = screen.getByRole("button", { name: /run tool/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(runButton);
|
||||
});
|
||||
|
||||
// Tool should not have been called due to validation failure
|
||||
expect(mockCallTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should allow tool execution when JSON validation passes", async () => {
|
||||
const mockCallTool = jest.fn();
|
||||
renderToolsTab({
|
||||
tools: [toolWithJsonParams],
|
||||
selectedTool: toolWithJsonParams,
|
||||
callTool: mockCallTool,
|
||||
});
|
||||
|
||||
// Find JSON editor textareas (should have one for each required field: config and data)
|
||||
const textareas = screen.getAllByRole("textbox");
|
||||
expect(textareas.length).toBe(2);
|
||||
|
||||
// Enter valid JSON in each textarea
|
||||
fireEvent.change(textareas[0], {
|
||||
target: { value: '{ "setting": "value" }' },
|
||||
});
|
||||
fireEvent.change(textareas[1], {
|
||||
target: { value: '["item1", "item2"]' },
|
||||
});
|
||||
|
||||
// Wait for debounced updates
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
});
|
||||
|
||||
// Try to run the tool
|
||||
const runButton = screen.getByRole("button", { name: /run tool/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(runButton);
|
||||
});
|
||||
|
||||
// Tool should have been called successfully
|
||||
expect(mockCallTool).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle mixed valid and invalid JSON parameters", async () => {
|
||||
const mockCallTool = jest.fn();
|
||||
renderToolsTab({
|
||||
tools: [toolWithJsonParams],
|
||||
selectedTool: toolWithJsonParams,
|
||||
callTool: mockCallTool,
|
||||
});
|
||||
|
||||
const textareas = screen.getAllByRole("textbox");
|
||||
|
||||
// Enter invalid JSON that contains both valid and invalid parts
|
||||
fireEvent.change(textareas[0], {
|
||||
target: {
|
||||
value:
|
||||
'{ "config": { "setting": "value" }, "data": ["unclosed array" }',
|
||||
},
|
||||
});
|
||||
|
||||
// Try to run the tool
|
||||
const runButton = screen.getByRole("button", { name: /run tool/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(runButton);
|
||||
});
|
||||
|
||||
// Tool should not have been called due to validation failure
|
||||
expect(mockCallTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should work with tools that have no JSON parameters", async () => {
|
||||
const mockCallTool = jest.fn();
|
||||
const simpleToolWithStringParam: Tool = {
|
||||
name: "simpleTool",
|
||||
description: "Tool with simple parameters",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
message: { type: "string" as const },
|
||||
count: { type: "number" as const },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
tools: [simpleToolWithStringParam],
|
||||
selectedTool: simpleToolWithStringParam,
|
||||
callTool: mockCallTool,
|
||||
});
|
||||
|
||||
// Fill in the simple parameters
|
||||
const messageInput = screen.getByRole("textbox");
|
||||
const countInput = screen.getByRole("spinbutton");
|
||||
|
||||
fireEvent.change(messageInput, { target: { value: "test message" } });
|
||||
fireEvent.change(countInput, { target: { value: "5" } });
|
||||
|
||||
// Run the tool
|
||||
const runButton = screen.getByRole("button", { name: /run tool/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(runButton);
|
||||
});
|
||||
|
||||
// Tool should have been called successfully (no JSON validation needed)
|
||||
expect(mockCallTool).toHaveBeenCalledWith(
|
||||
simpleToolWithStringParam.name,
|
||||
{
|
||||
message: "test message",
|
||||
count: 5,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty JSON parameters correctly", async () => {
|
||||
const mockCallTool = jest.fn();
|
||||
renderToolsTab({
|
||||
tools: [toolWithJsonParams],
|
||||
selectedTool: toolWithJsonParams,
|
||||
callTool: mockCallTool,
|
||||
});
|
||||
|
||||
const textareas = screen.getAllByRole("textbox");
|
||||
expect(textareas.length).toBe(2);
|
||||
|
||||
// Clear both textareas (empty JSON should be valid)
|
||||
fireEvent.change(textareas[0], { target: { value: "{}" } });
|
||||
fireEvent.change(textareas[1], { target: { value: "[]" } });
|
||||
|
||||
// Wait for debounced updates
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
});
|
||||
|
||||
// Try to run the tool
|
||||
const runButton = screen.getByRole("button", { name: /run tool/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(runButton);
|
||||
});
|
||||
|
||||
// Tool should have been called (empty JSON is considered valid)
|
||||
expect(mockCallTool).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
27
client/src/components/ui/switch.tsx
Normal file
27
client/src/components/ui/switch.tsx
Normal 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 };
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue