Merge branch 'main' into inspector/error-popup

This commit is contained in:
Cliff Hall 2026-01-09 15:52:22 -05:00 • committed by GitHub
commit 8cb845798e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 7119 additions and 2488 deletions

View file

@ -18,7 +18,16 @@ import {
LoggingLevel,
} from "@modelcontextprotocol/sdk/types.js";
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
import type {
AnySchema,
SchemaOutput,
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
import {
hasValidMetaName,
hasValidMetaPrefix,
isReservedMetaKey,
} from "@/utils/metaUtils";
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
import { OAuthStateMachine } from "./lib/oauth-state-machine";
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
@ -47,6 +56,7 @@ import {
Hash,
Key,
MessageSquare,
Settings,
} from "lucide-react";
import { z } from "zod";
@ -80,9 +90,28 @@ import {
CustomHeaders,
migrateFromLegacyAuth,
} from "./lib/types/customHeaders";
import MetadataTab from "./components/MetadataTab";
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
const filterReservedMetadata = (
metadata: Record<string, string>,
): Record<string, string> => {
return Object.entries(metadata).reduce<Record<string, string>>(
(acc, [key, value]) => {
if (
!isReservedMetaKey(key) &&
hasValidMetaPrefix(key) &&
hasValidMetaName(key)
) {
acc[key] = value;
}
return acc;
},
{},
);
};
const App = () => {
const [resources, setResources] = useState<Resource[]>([]);
const [resourceTemplates, setResourceTemplates] = useState<
@ -199,9 +228,31 @@ const App = () => {
const [authState, setAuthState] =
useState<AuthDebuggerState>(EMPTY_DEBUGGER_STATE);
// Metadata state - persisted in localStorage
const [metadata, setMetadata] = useState<Record<string, string>>(() => {
const savedMetadata = localStorage.getItem("lastMetadata");
if (savedMetadata) {
try {
const parsed = JSON.parse(savedMetadata);
if (parsed && typeof parsed === "object") {
return filterReservedMetadata(parsed);
}
} catch (error) {
console.warn("Failed to parse saved metadata:", error);
}
}
return {};
});
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
setAuthState((prev) => ({ ...prev, ...updates }));
};
const handleMetadataChange = (newMetadata: Record<string, string>) => {
const sanitizedMetadata = filterReservedMetadata(newMetadata);
setMetadata(sanitizedMetadata);
localStorage.setItem("lastMetadata", JSON.stringify(sanitizedMetadata));
};
const nextRequestId = useRef(0);
const rootsRef = useRef<Root[]>([]);
@ -249,6 +300,7 @@ const App = () => {
const {
connectionStatus,
serverCapabilities,
serverImplementation,
mcpClient,
requestHistory,
clearRequestHistory,
@ -304,6 +356,7 @@ const App = () => {
},
getRoots: () => rootsRef.current,
defaultLoggingLevel: logLevel,
metadata,
});
useEffect(() => {
@ -639,11 +692,11 @@ const App = () => {
setErrors((prev) => ({ ...prev, [tabKey]: null }));
};
const sendMCPRequest = async <T extends z.ZodType>(
const sendMCPRequest = async <T extends AnySchema>(
request: ClientRequest,
schema: T,
tabKey?: keyof typeof errors,
) => {
): Promise<SchemaOutput<T>> => {
try {
const response = await makeRequest(request, schema);
if (tabKey !== undefined) {
@ -784,7 +837,11 @@ const App = () => {
cacheToolOutputSchemas(response.tools);
};
const callTool = async (name: string, params: Record<string, unknown>) => {
const callTool = async (
name: string,
params: Record<string, unknown>,
toolMetadata?: Record<string, unknown>,
) => {
lastToolCallOriginTabRef.current = currentTabRef.current;
try {
@ -794,15 +851,21 @@ const App = () => {
? cleanParams(params, tool.inputSchema as JsonSchemaType)
: params;
// Merge general metadata with tool-specific metadata
// Tool-specific metadata takes precedence over general metadata
const mergedMetadata = {
...metadata, // General metadata
progressToken: progressTokenRef.current++,
...toolMetadata, // Tool-specific metadata
};
const response = await sendMCPRequest(
{
method: "tools/call" as const,
params: {
name,
arguments: cleanedParams,
_meta: {
progressToken: progressTokenRef.current++,
},
_meta: mergedMetadata,
},
},
CompatibilityCallToolResultSchema,
@ -920,6 +983,7 @@ const App = () => {
loggingSupported={!!serverCapabilities?.logging || false}
connectionType={connectionType}
setConnectionType={setConnectionType}
serverImplementation={serverImplementation}
/>
<div
onMouseDown={handleSidebarDragStart}
@ -1000,6 +1064,10 @@ const App = () => {
<Key className="w-4 h-4 mr-2" />
Auth
</TabsTrigger>
<TabsTrigger value="metadata">
<Settings className="w-4 h-4 mr-2" />
Metadata
</TabsTrigger>
</TabsList>
<div className="w-full">
@ -1110,10 +1178,14 @@ const App = () => {
setNextToolCursor(undefined);
cacheToolOutputSchemas([]);
}}
callTool={async (name, params) => {
callTool={async (
name: string,
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
) => {
clearError("tools");
setToolResult(null);
await callTool(name, params);
await callTool(name, params, metadata);
}}
selectedTool={selectedTool}
setSelectedTool={(tool) => {
@ -1156,6 +1228,10 @@ const App = () => {
onRootsChange={handleRootsChange}
/>
<AuthDebuggerWrapper />
<MetadataTab
metadata={metadata}
onMetadataChange={handleMetadataChange}
/>
</>
)}
</div>

View file

@ -49,6 +49,7 @@ const disconnectedConnectionState = {
completionsSupported: false,
connect: jest.fn(),
disconnect: jest.fn(),
serverImplementation: null,
};
// Connected state for tests that need an active connection

View file

@ -11,7 +11,11 @@ import { Input } from "@/components/ui/input";
import JsonEditor from "./JsonEditor";
import { updateValueAtPath } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import type {
JsonValue,
JsonSchemaType,
JsonSchemaConst,
} from "@/utils/jsonUtils";
import { useToast } from "@/lib/hooks/useToast";
import { CheckCheck, Copy } from "lucide-react";
@ -76,7 +80,40 @@ const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => {
const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
({ schema, value, onChange, maxDepth = 3 }, ref) => {
const isOnlyJSON = !isSimpleObject(schema);
// Determine if we can render a form at the top level.
// This is more permissive than isSimpleObject():
// - Objects with any properties are form-capable (individual complex fields may still fallback to JSON)
// - Arrays with defined items are form-capable
// - Primitive types are form-capable
const canRenderTopLevelForm = (s: JsonSchemaType): boolean => {
const primitiveTypes = ["string", "number", "integer", "boolean", "null"];
const hasType = Array.isArray(s.type) ? s.type.length > 0 : !!s.type;
if (!hasType) return false;
const includesType = (t: string) =>
Array.isArray(s.type)
? (s.type as ReadonlyArray<string>).includes(t)
: s.type === t;
// Primitive at top-level
if (primitiveTypes.some(includesType)) return true;
// Object with properties
if (includesType("object")) {
const keys = Object.keys(s.properties ?? {});
return keys.length > 0;
}
// Array with items
if (includesType("array")) {
return !!s.items;
}
return false;
};
const isOnlyJSON = !canRenderTopLevelForm(schema);
const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON);
const [jsonError, setJsonError] = useState<string>();
const [copiedJson, setCopiedJson] = useState<boolean>(false);
@ -113,8 +150,8 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
setJsonError(errorMessage);
// Reset to default for clearly invalid JSON (not just incomplete typing)
const trimmed = jsonString.trim();
if (trimmed.length > 5 && !trimmed.match(/^[\s[{]/)) {
const trimmed = jsonString?.trim();
if (trimmed && trimmed.length > 5 && !trimmed.match(/^[\s[{]/)) {
onChange(generateDefaultValue(schema));
}
}
@ -155,7 +192,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
const formatJson = () => {
try {
const jsonStr = rawJsonValue.trim();
const jsonStr = rawJsonValue?.trim();
if (!jsonStr) {
return;
}
@ -171,7 +208,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
const validateJson = () => {
if (!isJsonMode) return { isValid: true, error: null };
try {
const jsonStr = rawJsonValue.trim();
const jsonStr = rawJsonValue?.trim();
if (!jsonStr) return { isValid: true, error: null };
const parsed = JSON.parse(jsonStr);
// Clear any pending debounced update and immediately update parent
@ -267,63 +304,81 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
switch (fieldType) {
case "string": {
if (
propSchema.oneOf &&
propSchema.oneOf.every(
(option) =>
typeof option.const === "string" &&
typeof option.title === "string",
)
) {
// Titled single-select using oneOf/anyOf with const/title pairs
const titledOptions = (
(propSchema.oneOf ?? propSchema.anyOf) as
| (JsonSchemaType | JsonSchemaConst)[]
| undefined
)?.filter((opt): opt is JsonSchemaConst => "const" in opt);
if (titledOptions && titledOptions.length > 0) {
return (
<select
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
handleFieldChange(path, val);
}
}}
required={isRequired}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
>
<option value="">Select an option...</option>
{propSchema.oneOf.map((option) => (
<option
key={option.const as string}
value={option.const as string}
>
{option.title as string}
</option>
))}
</select>
<div className="space-y-2">
{propSchema.description && (
<p className="text-sm text-gray-600">
{propSchema.description}
</p>
)}
<select
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
handleFieldChange(path, val);
}
}}
required={isRequired}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
>
<option value="">Select an option...</option>
{titledOptions.map((option) => (
<option
key={String(option.const)}
value={String(option.const)}
>
{option.title ?? String(option.const)}
</option>
))}
</select>
</div>
);
}
// Untitled single-select using enum (with optional legacy enumNames for labels)
if (propSchema.enum) {
const names = Array.isArray(propSchema.enumNames)
? propSchema.enumNames
: undefined;
return (
<select
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
handleFieldChange(path, val);
}
}}
required={isRequired}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
>
<option value="">Select an option...</option>
{propSchema.enum.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<div className="space-y-2">
{propSchema.description && (
<p className="text-sm text-gray-600">
{propSchema.description}
</p>
)}
<select
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
handleFieldChange(path, val);
}
}}
required={isRequired}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
>
<option value="">Select an option...</option>
{propSchema.enum.map((option, idx) => (
<option key={option} value={option}>
{names?.[idx] ?? option}
</option>
))}
</select>
</div>
);
}
@ -413,13 +468,20 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
case "boolean":
return (
<Input
type="checkbox"
checked={(currentValue as boolean) ?? false}
onChange={(e) => handleFieldChange(path, e.target.checked)}
className="w-4 h-4"
required={isRequired}
/>
<div className="space-y-2">
{propSchema.description && (
<p className="text-sm text-gray-600">
{propSchema.description}
</p>
)}
<Input
type="checkbox"
checked={(currentValue as boolean) ?? false}
onChange={(e) => handleFieldChange(path, e.target.checked)}
className="w-4 h-4"
required={isRequired}
/>
</div>
);
case "null":
return null;
@ -449,7 +511,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
{Object.entries(propSchema.properties).map(([key, subSchema]) => (
<div key={key}>
<label className="block text-sm font-medium mb-1">
{key}
{(subSchema as JsonSchemaType).title ?? key}
{propSchema.required?.includes(key) && (
<span className="text-red-500 ml-1">*</span>
)}
@ -470,6 +532,72 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
if (!propSchema.items) return null;
// Special handling: array of enums -> render multi-select control
const itemSchema = propSchema.items as JsonSchemaType;
let multiOptions: { value: string; label: string }[] | null = null;
const titledMulti = (
(itemSchema.anyOf ?? itemSchema.oneOf) as
| (JsonSchemaType | JsonSchemaConst)[]
| undefined
)?.filter((opt): opt is JsonSchemaConst => "const" in opt);
if (titledMulti && titledMulti.length > 0) {
multiOptions = titledMulti.map((o) => ({
value: String(o.const),
label: o.title ?? String(o.const),
}));
} else if (itemSchema.enum) {
const names = Array.isArray(itemSchema.enumNames)
? itemSchema.enumNames
: undefined;
multiOptions = itemSchema.enum.map((v, i) => ({
value: v,
label: names?.[i] ?? v,
}));
}
if (multiOptions) {
const selectSize = Math.min(Math.max(multiOptions.length, 3), 8);
return (
<div className="space-y-2">
{propSchema.description && (
<p className="text-sm text-gray-600">
{propSchema.description}
</p>
)}
<select
multiple
size={selectSize}
value={arrayValue as string[]}
onChange={(e) => {
const selected = Array.from(
(e.target as HTMLSelectElement).selectedOptions,
).map((o) => o.value);
handleFieldChange(path, selected);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
>
{multiOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{(propSchema.minItems || propSchema.maxItems) && (
<p className="text-xs text-gray-500">
{propSchema.minItems
? `Select at least ${propSchema.minItems}. `
: ""}
{propSchema.maxItems
? `Select at most ${propSchema.maxItems}.`
: ""}
</p>
)}
</div>
);
}
// If the array items are simple, render as form fields, otherwise use JSON editor
if (isSimpleObject(propSchema.items)) {
return (
@ -629,6 +757,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
debouncedUpdateParent(newValue);
}}
error={jsonError}
placeholder={schema.description}
/>
) : // If schema type is object but value is not an object or is empty, and we have actual JSON data,
// render a simple representation of the JSON data

View file

@ -0,0 +1,58 @@
// Define Icon type locally since it might not be exported yet
interface Icon {
src: string;
mimeType?: string;
sizes?: string[];
theme?: "light" | "dark";
}
// Helper type for objects that may have icons
export interface WithIcons {
icons?: Icon[];
}
interface IconDisplayProps {
icons?: Icon[];
className?: string;
size?: "sm" | "md" | "lg";
}
const IconDisplay = ({
icons,
className = "",
size = "md",
}: IconDisplayProps) => {
if (!icons || icons.length === 0) {
return null;
}
const sizeClasses = {
sm: "w-4 h-4",
md: "w-6 h-6",
lg: "w-8 h-8",
};
const sizeClass = sizeClasses[size];
return (
<div className={`flex gap-1 ${className}`}>
{icons.map((icon, index) => (
<img
key={index}
src={icon.src}
alt=""
className={`${sizeClass} object-contain flex-shrink-0`}
style={{
imageRendering: "auto",
}}
onError={(e) => {
// Hide broken images
e.currentTarget.style.display = "none";
}}
/>
))}
</div>
);
};
export default IconDisplay;

View file

@ -8,12 +8,14 @@ interface JsonEditorProps {
value: string;
onChange: (value: string) => void;
error?: string;
placeholder?: string;
}
const JsonEditor = ({
value,
onChange,
error: externalError,
placeholder,
}: JsonEditorProps) => {
const [editorContent, setEditorContent] = useState(value || "");
const [internalError, setInternalError] = useState<string | undefined>(
@ -48,6 +50,7 @@ const JsonEditor = ({
Prism.highlight(code, Prism.languages.json, "json")
}
padding={10}
placeholder={placeholder}
style={{
fontFamily: '"Fira code", "Fira Mono", monospace',
fontSize: 14,

View file

@ -0,0 +1,170 @@
import React, { useState } from "react";
import { TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Trash2, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import {
META_NAME_RULES_MESSAGE,
META_PREFIX_RULES_MESSAGE,
RESERVED_NAMESPACE_MESSAGE,
hasValidMetaName,
hasValidMetaPrefix,
isReservedMetaKey,
} from "@/utils/metaUtils";
interface MetadataEntry {
key: string;
value: string;
}
interface MetadataTabProps {
metadata: Record<string, string>;
onMetadataChange: (metadata: Record<string, string>) => void;
}
const MetadataTab: React.FC<MetadataTabProps> = ({
metadata,
onMetadataChange,
}) => {
const [entries, setEntries] = useState<MetadataEntry[]>(() => {
return Object.entries(metadata).map(([key, value]) => ({ key, value }));
});
const addEntry = () => {
setEntries([...entries, { key: "", value: "" }]);
};
const removeEntry = (index: number) => {
const newEntries = entries.filter((_, i) => i !== index);
setEntries(newEntries);
updateMetadata(newEntries);
};
const updateEntry = (
index: number,
field: "key" | "value",
value: string,
) => {
const newEntries = [...entries];
newEntries[index][field] = value;
setEntries(newEntries);
updateMetadata(newEntries);
};
const updateMetadata = (newEntries: MetadataEntry[]) => {
const metadataObject: Record<string, string> = {};
newEntries.forEach(({ key, value }) => {
const trimmedKey = key.trim();
if (
trimmedKey &&
value.trim() &&
hasValidMetaPrefix(trimmedKey) &&
!isReservedMetaKey(trimmedKey) &&
hasValidMetaName(trimmedKey)
) {
metadataObject[trimmedKey] = value.trim();
}
});
onMetadataChange(metadataObject);
};
return (
<TabsContent value="metadata">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">Metadata</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Key-value pairs that will be included in all MCP requests
</p>
</div>
<Button onClick={addEntry} size="sm">
<Plus className="w-4 h-4 mr-2" />
Add Entry
</Button>
</div>
<div className="space-y-3">
{entries.map((entry, index) => {
const trimmedKey = entry.key.trim();
const hasInvalidPrefix =
trimmedKey !== "" && !hasValidMetaPrefix(trimmedKey);
const isReservedKey =
trimmedKey !== "" && isReservedMetaKey(trimmedKey);
const hasInvalidName =
trimmedKey !== "" && !hasValidMetaName(trimmedKey);
const validationMessage = hasInvalidPrefix
? META_PREFIX_RULES_MESSAGE
: isReservedKey
? RESERVED_NAMESPACE_MESSAGE
: hasInvalidName
? META_NAME_RULES_MESSAGE
: null;
return (
<div key={index} className="space-y-1">
<div className="flex items-center space-x-2">
<div className="flex-1">
<Label htmlFor={`key-${index}`} className="sr-only">
Key
</Label>
<Input
id={`key-${index}`}
placeholder="Key"
value={entry.key}
onChange={(e) =>
updateEntry(index, "key", e.target.value)
}
aria-invalid={Boolean(validationMessage)}
className={cn(
validationMessage &&
"border-red-500 focus-visible:ring-red-500 focus-visible:ring-1",
)}
/>
</div>
<div className="flex-1">
<Label htmlFor={`value-${index}`} className="sr-only">
Value
</Label>
<Input
id={`value-${index}`}
placeholder="Value"
value={entry.value}
onChange={(e) =>
updateEntry(index, "value", e.target.value)
}
disabled={Boolean(validationMessage)}
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => removeEntry(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
{validationMessage && (
<p className="text-xs text-red-600 dark:text-red-400">
{validationMessage}
</p>
)}
</div>
);
})}
</div>
{entries.length === 0 && (
<div className="text-center py-8">
<p className="text-gray-500 dark:text-gray-400 mb-4">
No metadata entries. Click "Add Entry" to add key-value pairs.
</p>
</div>
)}
</div>
</TabsContent>
);
};
export default MetadataTab;

View file

@ -9,11 +9,12 @@ import {
PromptReference,
ResourceReference,
} from "@modelcontextprotocol/sdk/types.js";
import { AlertCircle } from "lucide-react";
import { AlertCircle, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
import ListPane from "./ListPane";
import { useCompletionState } from "@/lib/hooks/useCompletionState";
import JsonView from "./JsonView";
import IconDisplay, { WithIcons } from "./IconDisplay";
export type Prompt = {
name: string;
@ -23,6 +24,12 @@ export type Prompt = {
description?: string;
required?: boolean;
}[];
icons?: {
src: string;
mimeType?: string;
sizes?: string[];
theme?: "light" | "dark";
}[];
};
const PromptsTab = ({
@ -108,11 +115,17 @@ const PromptsTab = ({
setPromptArgs({});
}}
renderItem={(prompt) => (
<div className="flex flex-col items-start">
<span className="flex-1">{prompt.name}</span>
<span className="text-sm text-gray-500 text-left">
{prompt.description}
</span>
<div className="flex items-start w-full gap-2">
<div className="flex-shrink-0 mt-1">
<IconDisplay icons={prompt.icons} size="sm" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="truncate">{prompt.name}</span>
<span className="text-sm text-gray-500 text-left line-clamp-2">
{prompt.description}
</span>
</div>
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400 mt-1" />
</div>
)}
title="Prompts"
@ -122,9 +135,17 @@ const PromptsTab = ({
<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">
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
</h3>
<div className="flex items-center gap-2">
{selectedPrompt && (
<IconDisplay
icons={(selectedPrompt as WithIcons).icons}
size="md"
/>
)}
<h3 className="font-semibold">
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
</h3>
</div>
</div>
<div className="p-4">
{error ? (

View file

@ -17,6 +17,7 @@ import { useEffect, useState } from "react";
import { useCompletionState } from "@/lib/hooks/useCompletionState";
import JsonView from "./JsonView";
import { UriTemplate } from "@modelcontextprotocol/sdk/shared/uriTemplate.js";
import IconDisplay, { WithIcons } from "./IconDisplay";
const ResourcesTab = ({
resources,
@ -129,7 +130,10 @@ const ResourcesTab = ({
}}
renderItem={(resource) => (
<div className="flex items-center w-full">
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
<IconDisplay icons={(resource as WithIcons).icons} size="sm" />
{!(resource as WithIcons).icons && (
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
)}
<span className="flex-1 truncate" title={resource.uri.toString()}>
{resource.name}
</span>
@ -159,7 +163,10 @@ const ResourcesTab = ({
}}
renderItem={(template) => (
<div className="flex items-center w-full">
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
<IconDisplay icons={(template as WithIcons).icons} size="sm" />
{!(template as WithIcons).icons && (
<FileText className="w-4 h-4 mr-2 flex-shrink-0 text-gray-500" />
)}
<span className="flex-1 truncate" title={template.uriTemplate}>
{template.name}
</span>
@ -175,16 +182,26 @@ const ResourcesTab = ({
<div className="bg-card border border-border rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-border flex justify-between items-center">
<h3
className="font-semibold truncate"
title={selectedResource?.name || selectedTemplate?.name}
>
{selectedResource
? selectedResource.name
: selectedTemplate
? selectedTemplate.name
: "Select a resource or template"}
</h3>
<div className="flex items-center gap-2 truncate">
{(selectedResource || selectedTemplate) && (
<IconDisplay
icons={
((selectedResource || selectedTemplate) as WithIcons).icons
}
size="md"
/>
)}
<h3
className="font-semibold truncate"
title={selectedResource?.name || selectedTemplate?.name}
>
{selectedResource
? selectedResource.name
: selectedTemplate
? selectedTemplate.name
: "Select a resource or template"}
</h3>
</div>
{selectedResource && (
<div className="flex row-auto gap-1 justify-end w-2/5">
{resourceSubscriptionsSupported &&

View file

@ -14,6 +14,7 @@ import {
RefreshCwOff,
Copy,
CheckCheck,
Server,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -40,6 +41,7 @@ import {
import CustomHeaders from "./CustomHeaders";
import { CustomHeaders as CustomHeadersType } from "@/lib/types/customHeaders";
import { useToast } from "../lib/hooks/useToast";
import IconDisplay, { WithIcons } from "./IconDisplay";
interface SidebarProps {
connectionStatus: ConnectionStatus;
@ -71,6 +73,9 @@ interface SidebarProps {
setConfig: (config: InspectorConfig) => void;
connectionType: "direct" | "proxy";
setConnectionType: (type: "direct" | "proxy") => void;
serverImplementation?:
| (WithIcons & { name?: string; version?: string; websiteUrl?: string })
| null;
}
const Sidebar = ({
@ -102,6 +107,7 @@ const Sidebar = ({
setConfig,
connectionType,
setConnectionType,
serverImplementation,
}: SidebarProps) => {
const [theme, setTheme] = useTheme();
const [showEnvVars, setShowEnvVars] = useState(false);
@ -776,6 +782,45 @@ const Sidebar = ({
</span>
</div>
{connectionStatus === "connected" && serverImplementation && (
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg mb-4">
<div className="flex items-center gap-2 mb-1">
{(serverImplementation as WithIcons).icons &&
(serverImplementation as WithIcons).icons!.length > 0 ? (
<IconDisplay
icons={(serverImplementation as WithIcons).icons}
size="sm"
/>
) : (
<Server className="w-4 h-4 text-gray-500" />
)}
{(serverImplementation as { websiteUrl?: string })
.websiteUrl ? (
<a
href={
(serverImplementation as { websiteUrl?: string })
.websiteUrl
}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline transition-colors"
>
{serverImplementation.name || "MCP Server"}
</a>
) : (
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
{serverImplementation.name || "MCP Server"}
</span>
)}
</div>
{serverImplementation.version && (
<div className="text-xs text-gray-500 dark:text-gray-400">
Version: {serverImplementation.version}
</div>
)}
</div>
)}
{loggingSupported && connectionStatus === "connected" && (
<div className="space-y-2">
<label

View file

@ -80,8 +80,8 @@ const ToolResults = ({
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
<JsonView data={toolResult} />
<h4 className="font-semibold mb-2">Errors:</h4>
{parsedResult.error.errors.map((error, idx) => (
<JsonView data={error} key={idx} />
{parsedResult.error.issues.map((issue, idx) => (
<JsonView data={issue} key={idx} />
))}
</>
);
@ -205,7 +205,8 @@ const ToolResults = ({
/>
)}
{item.type === "resource" &&
(item.resource?.mimeType?.startsWith("audio/") ? (
(item.resource?.mimeType?.startsWith("audio/") &&
"blob" in item.resource ? (
<audio
controls
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}

View file

@ -18,6 +18,7 @@ import {
generateDefaultValue,
isPropertyRequired,
normalizeUnionType,
resolveRef,
} from "@/utils/schemaUtils";
import {
CompatibilityCallToolResult,
@ -29,6 +30,7 @@ import {
Send,
ChevronDown,
ChevronUp,
ChevronRight,
AlertCircle,
Copy,
CheckCheck,
@ -39,6 +41,16 @@ import JsonView from "./JsonView";
import ToolResults from "./ToolResults";
import { useToast } from "@/lib/hooks/useToast";
import useCopy from "@/lib/hooks/useCopy";
import IconDisplay, { WithIcons } from "./IconDisplay";
import { cn } from "@/lib/utils";
import {
META_NAME_RULES_MESSAGE,
META_PREFIX_RULES_MESSAGE,
RESERVED_NAMESPACE_MESSAGE,
hasValidMetaName,
hasValidMetaPrefix,
isReservedMetaKey,
} from "@/utils/metaUtils";
// Type guard to safely detect the optional _meta field without using `any`
const hasMeta = (tool: Tool): tool is Tool & { _meta: unknown } =>
@ -60,7 +72,11 @@ const ToolsTab = ({
tools: Tool[];
listTools: () => void;
clearTools: () => void;
callTool: (name: string, params: Record<string, unknown>) => Promise<void>;
callTool: (
name: string,
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
) => Promise<void>;
selectedTool: Tool | null;
setSelectedTool: (tool: Tool | null) => void;
toolResult: CompatibilityCallToolResult | null;
@ -72,7 +88,10 @@ const ToolsTab = ({
const [params, setParams] = useState<Record<string, unknown>>({});
const [isToolRunning, setIsToolRunning] = useState(false);
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
const [isMetaExpanded, setIsMetaExpanded] = useState(false);
const [isMetadataExpanded, setIsMetadataExpanded] = useState(false);
const [metadataEntries, setMetadataEntries] = useState<
{ id: string; key: string; value: string }[]
>([]);
const [hasValidationErrors, setHasValidationErrors] = useState(false);
const formRefs = useRef<Record<string, DynamicJsonFormRef | null>>({});
const { toast } = useToast();
@ -90,14 +109,21 @@ const ToolsTab = ({
useEffect(() => {
const params = Object.entries(
selectedTool?.inputSchema.properties ?? [],
).map(([key, value]) => [
key,
generateDefaultValue(
).map(([key, value]) => {
// First resolve any $ref references
const resolvedValue = resolveRef(
value as JsonSchemaType,
key,
selectedTool?.inputSchema as JsonSchemaType,
),
]);
);
return [
key,
generateDefaultValue(
resolvedValue,
key,
selectedTool?.inputSchema as JsonSchemaType,
),
];
});
setParams(Object.fromEntries(params));
// Reset validation errors when switching tools
@ -107,6 +133,21 @@ const ToolsTab = ({
formRefs.current = {};
}, [selectedTool]);
const hasReservedMetadataEntry = metadataEntries.some(({ key }) => {
const trimmedKey = key.trim();
return trimmedKey !== "" && isReservedMetaKey(trimmedKey);
});
const hasInvalidMetaPrefixEntry = metadataEntries.some(({ key }) => {
const trimmedKey = key.trim();
return trimmedKey !== "" && !hasValidMetaPrefix(trimmedKey);
});
const hasInvalidMetaNameEntry = metadataEntries.some(({ key }) => {
const trimmedKey = key.trim();
return trimmedKey !== "" && !hasValidMetaName(trimmedKey);
});
return (
<TabsContent value="tools">
<div className="grid grid-cols-2 gap-4">
@ -119,11 +160,17 @@ const ToolsTab = ({
}}
setSelectedItem={setSelectedTool}
renderItem={(tool) => (
<div className="flex flex-col items-start">
<span className="flex-1">{tool.name}</span>
<span className="text-sm text-gray-500 text-left line-clamp-3">
{tool.description}
</span>
<div className="flex items-start w-full gap-2">
<div className="flex-shrink-0 mt-1">
<IconDisplay icons={(tool as WithIcons).icons} size="sm" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="truncate">{tool.name}</span>
<span className="text-sm text-gray-500 text-left line-clamp-2">
{tool.description}
</span>
</div>
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400 mt-1" />
</div>
)}
title="Tools"
@ -133,9 +180,17 @@ const ToolsTab = ({
<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">
{selectedTool ? selectedTool.name : "Select a tool"}
</h3>
<div className="flex items-center gap-2">
{selectedTool && (
<IconDisplay
icons={(selectedTool as WithIcons).icons}
size="md"
/>
)}
<h3 className="font-semibold">
{selectedTool ? selectedTool.name : "Select a tool"}
</h3>
</div>
</div>
<div className="p-4">
{selectedTool ? (
@ -154,7 +209,12 @@ const ToolsTab = ({
</p>
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => {
const prop = normalizeUnionType(value as JsonSchemaType);
// First resolve any $ref references
const resolvedValue = resolveRef(
value as JsonSchemaType,
selectedTool.inputSchema as JsonSchemaType,
);
const prop = normalizeUnionType(resolvedValue);
const inputSchema =
selectedTool.inputSchema as JsonSchemaType;
const required = isPropertyRequired(key, inputSchema);
@ -179,7 +239,20 @@ const ToolsTab = ({
onCheckedChange={(checked: boolean) =>
setParams({
...params,
[key]: checked ? null : prop.default,
[key]: checked
? null
: prop.type === "array"
? undefined
: prop.default !== null
? prop.default
: prop.type === "boolean"
? false
: prop.type === "string"
? ""
: prop.type === "number" ||
prop.type === "integer"
? undefined
: undefined,
})
}
/>
@ -372,6 +445,138 @@ const ToolsTab = ({
);
},
)}
<div className="pb-4">
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-semibold">
Tool-specific Metadata:
</h4>
<Button
size="sm"
variant="outline"
className="h-6 px-2"
onClick={() =>
setMetadataEntries((prev) => [
...prev,
{
id:
(
globalThis as unknown as {
crypto?: { randomUUID?: () => string };
}
).crypto?.randomUUID?.() ||
Math.random().toString(36).slice(2),
key: "",
value: "",
},
])
}
>
Add Pair
</Button>
</div>
{metadataEntries.length === 0 ? (
<p className="text-xs text-muted-foreground">
No metadata pairs.
</p>
) : (
<div className="space-y-2">
{metadataEntries.map((entry, index) => {
const trimmedKey = entry.key.trim();
const hasInvalidPrefix =
trimmedKey !== "" && !hasValidMetaPrefix(trimmedKey);
const isReservedKey =
trimmedKey !== "" && isReservedMetaKey(trimmedKey);
const hasInvalidName =
trimmedKey !== "" && !hasValidMetaName(trimmedKey);
const validationMessage = hasInvalidPrefix
? META_PREFIX_RULES_MESSAGE
: isReservedKey
? RESERVED_NAMESPACE_MESSAGE
: hasInvalidName
? META_NAME_RULES_MESSAGE
: null;
return (
<div key={entry.id} className="space-y-1">
<div className="flex items-center gap-2 w-full">
<Label
htmlFor={`metadata-key-${entry.id}`}
className="text-xs shrink-0"
>
Key
</Label>
<Input
id={`metadata-key-${entry.id}`}
value={entry.key}
placeholder="e.g. requestId"
onChange={(e) => {
const value = e.target.value;
setMetadataEntries((prev) =>
prev.map((m, i) =>
i === index ? { ...m, key: value } : m,
),
);
}}
className={cn(
"h-8 flex-1",
validationMessage &&
"border-red-500 focus-visible:ring-red-500 focus-visible:ring-1",
)}
aria-invalid={Boolean(validationMessage)}
/>
<Label
htmlFor={`metadata-value-${entry.id}`}
className="text-xs shrink-0"
>
Value
</Label>
<Input
id={`metadata-value-${entry.id}`}
value={entry.value}
placeholder="e.g. 12345"
onChange={(e) => {
const value = e.target.value;
setMetadataEntries((prev) =>
prev.map((m, i) =>
i === index ? { ...m, value } : m,
),
);
}}
className="h-8 flex-1"
disabled={Boolean(validationMessage)}
/>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 ml-auto shrink-0"
onClick={() =>
setMetadataEntries((prev) =>
prev.filter((_, i) => i !== index),
)
}
aria-label={`Remove meta pair ${index + 1}`}
>
-
</Button>
</div>
{validationMessage && (
<p className="text-xs text-red-600 dark:text-red-400">
{validationMessage}
</p>
)}
</div>
);
})}
</div>
)}
{(hasReservedMetadataEntry ||
hasInvalidMetaPrefixEntry ||
hasInvalidMetaNameEntry) && (
<p className="text-xs text-red-600 dark:text-red-400">
Remove reserved or invalid metadata keys (prefix/name)
before running the tool.
</p>
)}
</div>
{selectedTool.outputSchema && (
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
<div className="flex items-center justify-between mb-2">
@ -417,10 +622,12 @@ const ToolsTab = ({
<Button
size="sm"
variant="ghost"
onClick={() => setIsMetaExpanded(!isMetaExpanded)}
onClick={() =>
setIsMetadataExpanded(!isMetadataExpanded)
}
className="h-6 px-2"
>
{isMetaExpanded ? (
{isMetadataExpanded ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Collapse
@ -435,40 +642,66 @@ const ToolsTab = ({
</div>
<div
className={`transition-all ${
isMetaExpanded ? "" : "max-h-[8rem] overflow-y-auto"
isMetadataExpanded
? ""
: "max-h-[8rem] overflow-y-auto"
}`}
>
<JsonView data={selectedTool._meta} />
</div>
</div>
)}
<div className="flex gap-2">
<Button
onClick={async () => {
// Validate JSON inputs before calling tool
if (checkValidationErrors()) return;
<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>
try {
setIsToolRunning(true);
const metadata = metadataEntries.reduce<
Record<string, unknown>
>((acc, { key, value }) => {
const trimmedKey = key.trim();
if (
trimmedKey !== "" &&
hasValidMetaPrefix(trimmedKey) &&
!isReservedMetaKey(trimmedKey) &&
hasValidMetaName(trimmedKey)
) {
acc[trimmedKey] = value;
}
return acc;
}, {});
await callTool(
selectedTool.name,
params,
Object.keys(metadata).length ? metadata : undefined,
);
} finally {
setIsToolRunning(false);
}
}}
disabled={
isToolRunning ||
hasValidationErrors ||
hasReservedMetadataEntry ||
hasInvalidMetaPrefixEntry ||
hasInvalidMetaNameEntry
}
>
{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 {

View file

@ -1,4 +1,5 @@
import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils";
@ -104,7 +105,11 @@ describe("DynamicJsonForm Array Fields", () => {
it("should render JSON editor for complex arrays", () => {
renderComplexArrayForm();
// Should render as JSON editor (textarea)
// Initially renders form view with Switch to JSON button; switch to JSON to see textarea
const switchBtn = screen.getByRole("button", { name: /switch to json/i });
expect(switchBtn).toBeInTheDocument();
fireEvent.click(switchBtn);
const textarea = screen.getByRole("textbox");
expect(textarea).toHaveProperty("type", "textarea");
@ -173,6 +178,81 @@ describe("DynamicJsonForm Array Fields", () => {
});
describe("Array with Different Item Types", () => {
it("should render multi-select for untitled items.enum with helper text", () => {
const schema: JsonSchemaType = {
type: "array",
title: "Colors",
description: "Pick colors",
minItems: 1,
maxItems: 3,
items: { type: "string", enum: ["red", "green", "blue"] },
} as JsonSchemaType;
const onChange = jest.fn();
render(
<DynamicJsonForm schema={schema} value={["red"]} onChange={onChange} />,
);
// Description visible
expect(screen.getByText("Pick colors")).toBeInTheDocument();
// Multi-select present
const listbox = screen.getByRole("listbox");
expect(listbox).toHaveAttribute("multiple");
// Helper text shows min/max
expect(screen.getByText(/Select at least 1/i)).toBeInTheDocument();
expect(screen.getByText(/Select at most 3/i)).toBeInTheDocument();
// Select another option by toggling option.selected
const colorOptions = screen.getAllByRole("option");
// options: red, green, blue
colorOptions[0].selected = true; // red
colorOptions[2].selected = true; // blue
fireEvent.change(listbox);
expect(onChange).toHaveBeenCalledWith(["red", "blue"]);
});
it("should render titled multi-select for items.anyOf with const/title", () => {
const schema: JsonSchemaType = {
type: "array",
description: "Pick fish",
items: {
anyOf: [
{ const: "fish-1", title: "Tuna" },
{ const: "fish-2", title: "Salmon" },
{ const: "fish-3", title: "Trout" },
],
} as unknown as JsonSchemaType,
} as unknown as JsonSchemaType;
const onChange = jest.fn();
render(
<DynamicJsonForm
schema={schema}
value={["fish-1"]}
onChange={onChange}
/>,
);
// Description visible
expect(screen.getByText("Pick fish")).toBeInTheDocument();
const listbox = screen.getByRole("listbox");
expect(listbox).toHaveAttribute("multiple");
// Ensure options have titles as labels
const options = screen.getAllByRole("option");
expect(options[0]).toHaveProperty("textContent", "Tuna");
expect(options[1]).toHaveProperty("textContent", "Salmon");
expect(options[2]).toHaveProperty("textContent", "Trout");
// Select fish-2 and fish-3
const fishOptions = screen.getAllByRole("option");
// options: Tuna (fish-1), Salmon (fish-2), Trout (fish-3)
fishOptions[0].selected = false; // deselect fish-1
fishOptions[1].selected = true; // fish-2
fishOptions[2].selected = true; // fish-3
fireEvent.change(listbox);
expect(onChange).toHaveBeenCalledWith(["fish-2", "fish-3"]);
});
it("should handle integer array items", () => {
const schema = {
type: "array" as const,

View file

@ -202,6 +202,55 @@ describe("DynamicJsonForm String Fields", () => {
fireEvent.change(select, { target: { value: "amber" } });
expect(onChange).toHaveBeenCalledWith("amber");
});
it("should render anyOf with const/title for labeled options and show description", () => {
const onChange = jest.fn();
const schema: JsonSchemaType = {
type: "string",
title: "Heroes",
description: "Choose a hero",
anyOf: [
{ const: "hero-1", title: "Superman" },
{ const: "hero-2", title: "Batman" },
],
};
render(<DynamicJsonForm schema={schema} value="" onChange={onChange} />);
// Description should be visible above the select
expect(screen.getByText("Choose a hero")).toBeInTheDocument();
const select = screen.getByRole("combobox");
const options = screen.getAllByRole("option");
expect(options).toHaveLength(3);
expect(options[1]).toHaveProperty("textContent", "Superman");
expect(options[2]).toHaveProperty("textContent", "Batman");
fireEvent.change(select, { target: { value: "hero-2" } });
expect(onChange).toHaveBeenCalledWith("hero-2");
});
it("should render legacy enum with enumNames as labels", () => {
const onChange = jest.fn();
const schema: JsonSchemaType = {
type: "string",
title: "Pets",
description: "Choose a pet",
enum: ["pet-1", "pet-2", "pet-3"],
enumNames: ["Cat", "Dog", "Bird"],
} as unknown as JsonSchemaType; // enumNames is legacy extension
render(<DynamicJsonForm schema={schema} value="" onChange={onChange} />);
// Description should be visible above the select
expect(screen.getByText("Choose a pet")).toBeInTheDocument();
const options = screen.getAllByRole("option");
expect(options[1]).toHaveProperty("textContent", "Cat");
expect(options[2]).toHaveProperty("textContent", "Dog");
expect(options[3]).toHaveProperty("textContent", "Bird");
const select = screen.getByRole("combobox");
fireEvent.change(select, { target: { value: "pet-2" } });
expect(onChange).toHaveBeenCalledWith("pet-2");
});
});
describe("Validation Attributes", () => {
@ -535,8 +584,8 @@ describe("DynamicJsonForm Object Fields", () => {
<DynamicJsonForm schema={schema} value={{}} onChange={jest.fn()} />,
);
const nameLabel = screen.getByText("name");
const optionalLabel = screen.getByText("optional");
const nameLabel = screen.getByText("Name");
const optionalLabel = screen.getByText("Optional");
const nameInput = nameLabel.closest("div")?.querySelector("input");
const optionalInput = optionalLabel
@ -567,30 +616,46 @@ describe("DynamicJsonForm Complex Fields", () => {
};
describe("Basic Operations", () => {
it("should render textbox and autoformat button, but no switch-to-form button", () => {
it("should allow switching to JSON mode and show copy/format buttons", () => {
renderForm();
// Initially renders as a form with a Switch to JSON button
const switchToJson = screen.getByRole("button", {
name: /switch to json/i,
});
expect(switchToJson).toBeInTheDocument();
// Switch to JSON mode
fireEvent.click(switchToJson);
// Now a textarea and JSON helpers should be visible
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "textarea");
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(2); // Copy JSON + Format JSON
const copyButton = screen.getByRole("button", { name: /copy json/i });
const formatButton = screen.getByRole("button", { name: /format json/i });
expect(copyButton).toBeTruthy();
expect(formatButton).toBeTruthy();
// And a Switch to Form button should appear
expect(
screen.getByRole("button", { name: /switch to form/i }),
).toBeInTheDocument();
});
it("should pass changed values to onChange", () => {
it("should pass changed values to onChange in JSON mode", () => {
const onChange = jest.fn();
renderForm({ onChange });
// Switch to JSON mode first
fireEvent.click(screen.getByRole("button", { name: /switch to json/i }));
const input = screen.getByRole("textbox");
fireEvent.change(input, {
target: { value: `{ "nested": "i am string" }` },
});
// The onChange handler is debounced when using the JSON view, so we need to wait a little bit
waitFor(() => {
expect(onChange).toHaveBeenCalledWith(`{ "nested": "i am string" }`);
return waitFor(() => {
expect(onChange).toHaveBeenCalledWith({ nested: "i am string" });
});
});
});
@ -626,6 +691,10 @@ describe("DynamicJsonForm Copy JSON Functionality", () => {
it("should render Copy JSON button when in JSON mode", () => {
renderFormInJsonMode();
// Switch to JSON mode to reveal copy/format buttons
const switchBtn = screen.getByRole("button", { name: /switch to json/i });
fireEvent.click(switchBtn);
const copyButton = screen.getByRole("button", { name: "Copy JSON" });
expect(copyButton).toBeTruthy();
});
@ -653,6 +722,10 @@ describe("DynamicJsonForm Copy JSON Functionality", () => {
renderFormInJsonMode({ value: testValue });
// Switch to JSON mode first
const switchBtn = screen.getByRole("button", { name: /switch to json/i });
fireEvent.click(switchBtn);
const copyButton = screen.getByRole("button", { name: "Copy JSON" });
fireEvent.click(copyButton);
@ -768,6 +841,10 @@ describe("DynamicJsonForm Validation Functionality", () => {
it("should return valid for valid JSON in JSON mode", () => {
renderFormWithRef();
// Switch to JSON mode to enable textarea editing/validation
const switchBtn = screen.getByRole("button", { name: /switch to json/i });
fireEvent.click(switchBtn);
const validateButton = screen.getByTestId("validate-button");
fireEvent.click(validateButton);
@ -778,6 +855,10 @@ describe("DynamicJsonForm Validation Functionality", () => {
it("should return invalid for malformed JSON in JSON mode", async () => {
renderFormWithRef();
// Switch to JSON mode first
const switchBtn = screen.getByRole("button", { name: /switch to json/i });
fireEvent.click(switchBtn);
// Enter invalid JSON
const textarea = screen.getByRole("textbox");
fireEvent.change(textarea, { target: { value: '{ "invalid": json }' } });
@ -799,6 +880,10 @@ describe("DynamicJsonForm Validation Functionality", () => {
it("should return valid for empty JSON in JSON mode", () => {
renderFormWithRef();
// Switch to JSON mode first
const switchBtn = screen.getByRole("button", { name: /switch to json/i });
fireEvent.click(switchBtn);
// Clear the textarea
const textarea = screen.getByRole("textbox");
fireEvent.change(textarea, { target: { value: "" } });
@ -813,6 +898,10 @@ describe("DynamicJsonForm Validation Functionality", () => {
it("should set error state when validation fails", async () => {
renderFormWithRef();
// Switch to JSON mode first
const switchBtn = screen.getByRole("button", { name: /switch to json/i });
fireEvent.click(switchBtn);
// Enter invalid JSON
const textarea = screen.getByRole("textbox");
fireEvent.change(textarea, {

View file

@ -28,7 +28,7 @@ describe("HistoryAndNotifications", () => {
method: "notifications/message",
params: {
level: "info" as const,
message: "First notification",
data: "First notification",
},
},
{
@ -36,7 +36,7 @@ describe("HistoryAndNotifications", () => {
params: {
progressToken: "test-token",
progress: 50,
message: "Second notification",
total: 100,
},
},
];

View file

@ -0,0 +1,593 @@
import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import MetadataTab from "../MetadataTab";
import { Tabs } from "@/components/ui/tabs";
import {
META_NAME_RULES_MESSAGE,
META_PREFIX_RULES_MESSAGE,
RESERVED_NAMESPACE_MESSAGE,
} from "@/utils/metaUtils";
describe("MetadataTab", () => {
const defaultProps = {
metadata: {},
onMetadataChange: jest.fn(),
};
const renderMetadataTab = (props = {}) => {
return render(
<Tabs defaultValue="metadata">
<MetadataTab {...defaultProps} {...props} />
</Tabs>,
);
};
beforeEach(() => {
jest.clearAllMocks();
});
describe("Initial Rendering", () => {
it("should render the metadata tab with title and description", () => {
renderMetadataTab();
expect(screen.getByText("Metadata")).toBeInTheDocument();
expect(
screen.getByText(
"Key-value pairs that will be included in all MCP requests",
),
).toBeInTheDocument();
});
it("should render Add Entry button", () => {
renderMetadataTab();
const addButton = screen.getByRole("button", { name: /add entry/i });
expect(addButton).toBeInTheDocument();
});
it("should show empty state message when no entries exist", () => {
renderMetadataTab();
expect(
screen.getByText(
'No metadata entries. Click "Add Entry" to add key-value pairs.',
),
).toBeInTheDocument();
});
it("should not show empty state message when entries exist", () => {
renderMetadataTab({
metadata: { key1: "value1" },
});
expect(
screen.queryByText(
'No metadata entries. Click "Add Entry" to add key-value pairs.',
),
).not.toBeInTheDocument();
});
});
describe("Initial Data Handling", () => {
it("should initialize with existing metadata", () => {
const initialMetadata = {
API_KEY: "test-key",
VERSION: "1.0.0",
};
renderMetadataTab({ metadata: initialMetadata });
expect(screen.getByDisplayValue("API_KEY")).toBeInTheDocument();
expect(screen.getByDisplayValue("test-key")).toBeInTheDocument();
expect(screen.getByDisplayValue("VERSION")).toBeInTheDocument();
expect(screen.getByDisplayValue("1.0.0")).toBeInTheDocument();
});
it("should render multiple entries in correct order", () => {
const initialMetadata = {
FIRST: "first-value",
SECOND: "second-value",
THIRD: "third-value",
};
renderMetadataTab({ metadata: initialMetadata });
const keyInputs = screen.getAllByPlaceholderText("Key");
const valueInputs = screen.getAllByPlaceholderText("Value");
expect(keyInputs).toHaveLength(3);
expect(valueInputs).toHaveLength(3);
// Check that entries are rendered in the order they appear in the object
const entries = Object.entries(initialMetadata);
entries.forEach(([key, value], index) => {
expect(keyInputs[index]).toHaveValue(key);
expect(valueInputs[index]).toHaveValue(value);
});
});
});
describe("Adding Entries", () => {
it("should add a new empty entry when Add Entry button is clicked", () => {
renderMetadataTab();
const addButton = screen.getByRole("button", { name: /add entry/i });
fireEvent.click(addButton);
const keyInputs = screen.getAllByPlaceholderText("Key");
const valueInputs = screen.getAllByPlaceholderText("Value");
expect(keyInputs).toHaveLength(1);
expect(valueInputs).toHaveLength(1);
expect(keyInputs[0]).toHaveValue("");
expect(valueInputs[0]).toHaveValue("");
});
it("should add multiple entries when Add Entry button is clicked multiple times", () => {
renderMetadataTab();
const addButton = screen.getByRole("button", { name: /add entry/i });
fireEvent.click(addButton);
fireEvent.click(addButton);
fireEvent.click(addButton);
const keyInputs = screen.getAllByPlaceholderText("Key");
const valueInputs = screen.getAllByPlaceholderText("Value");
expect(keyInputs).toHaveLength(3);
expect(valueInputs).toHaveLength(3);
});
it("should hide empty state message after adding first entry", () => {
renderMetadataTab();
expect(
screen.getByText(
'No metadata entries. Click "Add Entry" to add key-value pairs.',
),
).toBeInTheDocument();
const addButton = screen.getByRole("button", { name: /add entry/i });
fireEvent.click(addButton);
expect(
screen.queryByText(
'No metadata entries. Click "Add Entry" to add key-value pairs.',
),
).not.toBeInTheDocument();
});
});
describe("Removing Entries", () => {
it("should render remove button for each entry", () => {
renderMetadataTab({
metadata: { key1: "value1", key2: "value2" },
});
const removeButtons = screen.getAllByRole("button", { name: "" }); // Trash icon buttons have no text
expect(removeButtons).toHaveLength(2);
});
it("should remove entry when remove button is clicked", () => {
const onMetadataChange = jest.fn();
renderMetadataTab({
metadata: { key1: "value1", key2: "value2" },
onMetadataChange,
});
const removeButtons = screen.getAllByRole("button", { name: "" });
fireEvent.click(removeButtons[0]);
expect(onMetadataChange).toHaveBeenCalledWith({ key2: "value2" });
});
it("should remove correct entry when multiple entries exist", () => {
const onMetadataChange = jest.fn();
renderMetadataTab({
metadata: {
FIRST: "first-value",
SECOND: "second-value",
THIRD: "third-value",
},
onMetadataChange,
});
const removeButtons = screen.getAllByRole("button", { name: "" });
fireEvent.click(removeButtons[1]); // Remove second entry
expect(onMetadataChange).toHaveBeenCalledWith({
FIRST: "first-value",
THIRD: "third-value",
});
});
it("should show empty state message after removing all entries", () => {
const onMetadataChange = jest.fn();
renderMetadataTab({
metadata: { key1: "value1" },
onMetadataChange,
});
const removeButton = screen.getByRole("button", { name: "" });
fireEvent.click(removeButton);
expect(onMetadataChange).toHaveBeenCalledWith({});
});
});
describe("Editing Entries", () => {
it("should update key when key input is changed", () => {
const onMetadataChange = jest.fn();
renderMetadataTab({
metadata: { oldKey: "value1" },
onMetadataChange,
});
const keyInput = screen.getByDisplayValue("oldKey");
fireEvent.change(keyInput, { target: { value: "newKey" } });
expect(onMetadataChange).toHaveBeenCalledWith({ newKey: "value1" });
});
it("should update value when value input is changed", () => {
const onMetadataChange = jest.fn();
renderMetadataTab({
metadata: { key1: "oldValue" },
onMetadataChange,
});
const valueInput = screen.getByDisplayValue("oldValue");
fireEvent.change(valueInput, { target: { value: "newValue" } });
expect(onMetadataChange).toHaveBeenCalledWith({ key1: "newValue" });
});
it("should handle editing multiple entries independently", () => {
const onMetadataChange = jest.fn();
renderMetadataTab({
metadata: {
key1: "value1",
key2: "value2",
},
onMetadataChange,
});
const keyInputs = screen.getAllByPlaceholderText("Key");
const valueInputs = screen.getAllByPlaceholderText("Value");
// Edit first entry key
fireEvent.change(keyInputs[0], { target: { value: "newKey1" } });
expect(onMetadataChange).toHaveBeenCalledWith({
newKey1: "value1",
key2: "value2",
});
// Clear mock to test second edit independently
onMetadataChange.mockClear();
// Edit second entry value
fireEvent.change(valueInputs[1], { target: { value: "newValue2" } });
expect(onMetadataChange).toHaveBeenCalledWith({
newKey1: "value1",
key2: "newValue2",
});
});
});
describe("Reserved Metadata Keys", () => {
test.each`
description | value | message | shouldDisableValue
${"reserved keys with prefix"} | ${"modelcontextprotocol.io/flip"} | ${RESERVED_NAMESPACE_MESSAGE} | ${true}
${"reserved root without slash"} | ${"modelcontextprotocol.io"} | ${RESERVED_NAMESPACE_MESSAGE} | ${true}
${"nested modelcontextprotocol domain"} | ${"api.modelcontextprotocol.org"} | ${RESERVED_NAMESPACE_MESSAGE} | ${false}
${"nested mcp domain"} | ${"tools.mcp.com/path"} | ${RESERVED_NAMESPACE_MESSAGE} | ${false}
${"invalid name segments"} | ${"custom/bad-"} | ${META_NAME_RULES_MESSAGE} | ${false}
${"invalid prefix labels"} | ${"1invalid-prefix/value"} | ${META_PREFIX_RULES_MESSAGE} | ${false}
`(
"should display an error for $description",
({ value, message, shouldDisableValue }) => {
const onMetadataChange = jest.fn();
renderMetadataTab({ onMetadataChange });
const addButton = screen.getByRole("button", { name: /add entry/i });
fireEvent.click(addButton);
const keyInput = screen.getByPlaceholderText("Key");
fireEvent.change(keyInput, { target: { value } });
const valueInput = screen.getByPlaceholderText("Value");
if (shouldDisableValue) {
expect(valueInput).toBeDisabled();
}
expect(screen.getByText(message)).toBeInTheDocument();
expect(onMetadataChange).toHaveBeenLastCalledWith({});
},
);
});
describe("Data Validation and Trimming", () => {
it("should trim whitespace from keys and values", () => {
const onMetadataChange = jest.fn();
renderMetadataTab({ onMetadataChange });
const addButton = screen.getByRole("button", { name: /add entry/i });
fireEvent.click(addButton);
const keyInput = screen.getByPlaceholderText("Key");
const valueInput = screen.getByPlaceholderText("Value");
fireEvent.change(keyInput, { target: { value: " trimmedKey " } });
fireEvent.change(valueInput, { target: { value: " trimmedValue " } });
expect(onMetadataChange).toHaveBeenCalledWith({
trimmedKey: "trimmedValue",
});
});
it("should exclude entries with empty keys or values after trimming", () => {
const onMetadataChange = jest.fn();
renderMetadataTab({ onMetadataChange });
const addButton = screen.getByRole("button", { name: /add entry/i });
fireEvent.click(addButton);
fireEvent.click(addButton);
const keyInputs = screen.getAllByPlaceholderText("Key");
const valueInputs = screen.getAllByPlaceholderText("Value");
// First entry: valid key and value
fireEvent.change(keyInputs[0], { target: { value: "validKey" } });
fireEvent.change(valueInputs[0], { target: { value: "validValue" } });
// Second entry: empty key (should be excluded)
fireEvent.change(keyInputs[1], { target: { value: "" } });
fireEvent.change(valueInputs[1], { target: { value: "someValue" } });
expect(onMetadataChange).toHaveBeenCalledWith({
validKey: "validValue",
});
});
it("should exclude entries with whitespace-only keys or values", () => {
const onMetadataChange = jest.fn();
renderMetadataTab({ onMetadataChange });
const addButton = screen.getByRole("button", { name: /add entry/i });
fireEvent.click(addButton);
fireEvent.click(addButton);
const keyInputs = screen.getAllByPlaceholderText("Key");
const valueInputs = screen.getAllByPlaceholderText("Value");
// First entry: valid key and value
fireEvent.change(keyInputs[0], { target: { value: "validKey" } });
fireEvent.change(valueInputs[0], { target: { value: "validValue" } });
// Second entry: whitespace-only key (should be excluded)
fireEvent.change(keyInputs[1], { target: { value: " " } });
fireEvent.change(valueInputs[1], { target: { value: "someValue" } });
expect(onMetadataChange).toHaveBeenCalledWith({
validKey: "validValue",
});
});
it("should handle mixed valid and invalid entries", () => {
const onMetadataChange = jest.fn();
renderMetadataTab({ onMetadataChange });
const addButton = screen.getByRole("button", { name: /add entry/i });
fireEvent.click(addButton);
fireEvent.click(addButton);
fireEvent.click(addButton);
const keyInputs = screen.getAllByPlaceholderText("Key");
const valueInputs = screen.getAllByPlaceholderText("Value");
// First entry: valid
fireEvent.change(keyInputs[0], { target: { value: "key1" } });
fireEvent.change(valueInputs[0], { target: { value: "value1" } });
// Second entry: empty key (invalid)
fireEvent.change(keyInputs[1], { target: { value: "" } });
fireEvent.change(valueInputs[1], { target: { value: "value2" } });
// Third entry: valid
fireEvent.change(keyInputs[2], { target: { value: "key3" } });
fireEvent.change(valueInputs[2], { target: { value: "value3" } });
expect(onMetadataChange).toHaveBeenCalledWith({
key1: "value1",
key3: "value3",
});
});
});
describe("Input Accessibility", () => {
it("should have proper labels for screen readers", () => {
renderMetadataTab({
metadata: { key1: "value1" },
});
const keyLabel = screen.getByLabelText("Key", { selector: "input" });
const valueLabel = screen.getByLabelText("Value", { selector: "input" });
expect(keyLabel).toBeInTheDocument();
expect(valueLabel).toBeInTheDocument();
});
it("should have unique IDs for each input pair", () => {
renderMetadataTab({
metadata: {
key1: "value1",
key2: "value2",
},
});
const keyInputs = screen.getAllByPlaceholderText("Key");
const valueInputs = screen.getAllByPlaceholderText("Value");
expect(keyInputs[0]).toHaveAttribute("id", "key-0");
expect(keyInputs[1]).toHaveAttribute("id", "key-1");
expect(valueInputs[0]).toHaveAttribute("id", "value-0");
expect(valueInputs[1]).toHaveAttribute("id", "value-1");
});
it("should have proper placeholder text", () => {
renderMetadataTab();
const addButton = screen.getByRole("button", { name: /add entry/i });
fireEvent.click(addButton);
const keyInput = screen.getByPlaceholderText("Key");
const valueInput = screen.getByPlaceholderText("Value");
expect(keyInput).toHaveAttribute("placeholder", "Key");
expect(valueInput).toHaveAttribute("placeholder", "Value");
});
});
describe("Edge Cases", () => {
it("should flag invalid names that contain unsupported characters", () => {
const onMetadataChange = jest.fn();
renderMetadataTab({ onMetadataChange });
const addButton = screen.getByRole("button", { name: /add entry/i });
fireEvent.click(addButton);
const keyInput = screen.getByPlaceholderText("Key");
const valueInput = screen.getByPlaceholderText("Value");
fireEvent.change(keyInput, {
target: { value: "key-with-special@chars!" },
});
fireEvent.change(valueInput, {
target: { value: "value with spaces & symbols $%^" },
});
expect(screen.getByText(META_NAME_RULES_MESSAGE)).toBeInTheDocument();
expect(onMetadataChange).toHaveBeenLastCalledWith({});
});
it("should reject unicode names that do not start with an alphanumeric character", () => {
const onMetadataChange = jest.fn();
renderMetadataTab({ onMetadataChange });
const addButton = screen.getByRole("button", { name: /add entry/i });
fireEvent.click(addButton);
const keyInput = screen.getByPlaceholderText("Key");
const valueInput = screen.getByPlaceholderText("Value");
fireEvent.change(keyInput, { target: { value: "🔑_key" } });
fireEvent.change(valueInput, { target: { value: "值_value_🎯" } });
expect(screen.getByText(META_NAME_RULES_MESSAGE)).toBeInTheDocument();
expect(onMetadataChange).toHaveBeenLastCalledWith({});
});
it("should handle very long keys and values", () => {
const onMetadataChange = jest.fn();
renderMetadataTab({ onMetadataChange });
const addButton = screen.getByRole("button", { name: /add entry/i });
fireEvent.click(addButton);
const keyInput = screen.getByPlaceholderText("Key");
const valueInput = screen.getByPlaceholderText("Value");
const longKey = "A".repeat(100);
const longValue = "B".repeat(500);
fireEvent.change(keyInput, { target: { value: longKey } });
fireEvent.change(valueInput, { target: { value: longValue } });
expect(onMetadataChange).toHaveBeenCalledWith({
[longKey]: longValue,
});
});
it("should handle duplicate keys by keeping the last one", () => {
const onMetadataChange = jest.fn();
renderMetadataTab({ onMetadataChange });
const addButton = screen.getByRole("button", { name: /add entry/i });
fireEvent.click(addButton);
fireEvent.click(addButton);
const keyInputs = screen.getAllByPlaceholderText("Key");
const valueInputs = screen.getAllByPlaceholderText("Value");
// Set same key for both entries
fireEvent.change(keyInputs[0], { target: { value: "duplicateKey" } });
fireEvent.change(valueInputs[0], { target: { value: "firstValue" } });
fireEvent.change(keyInputs[1], { target: { value: "duplicateKey" } });
fireEvent.change(valueInputs[1], { target: { value: "secondValue" } });
// The second value should overwrite the first
expect(onMetadataChange).toHaveBeenCalledWith({
duplicateKey: "secondValue",
});
});
});
describe("Integration with Parent Component", () => {
it("should not call onMetadataChange when component mounts with existing data", () => {
const onMetadataChange = jest.fn();
renderMetadataTab({
metadata: { key1: "value1" },
onMetadataChange,
});
expect(onMetadataChange).not.toHaveBeenCalled();
});
it("should call onMetadataChange only when user makes changes", () => {
const onMetadataChange = jest.fn();
renderMetadataTab({
metadata: { key1: "value1" },
onMetadataChange,
});
// Should not be called on mount
expect(onMetadataChange).not.toHaveBeenCalled();
// Should be called when user changes value
const valueInput = screen.getByDisplayValue("value1");
fireEvent.change(valueInput, { target: { value: "newValue" } });
expect(onMetadataChange).toHaveBeenCalledTimes(1);
expect(onMetadataChange).toHaveBeenCalledWith({ key1: "newValue" });
});
it("should maintain internal state when props change (component doesn't sync with prop changes)", () => {
const { rerender } = renderMetadataTab({
metadata: { key1: "value1" },
});
expect(screen.getByDisplayValue("key1")).toBeInTheDocument();
expect(screen.getByDisplayValue("value1")).toBeInTheDocument();
// Rerender with different props - component should maintain its internal state
// This is the intended behavior since useState initializer only runs once
rerender(
<Tabs defaultValue="metadata">
<MetadataTab
metadata={{ key2: "value2", key3: "value3" }}
onMetadataChange={jest.fn()}
/>
</Tabs>,
);
// The component should still show the original values since it maintains internal state
expect(screen.getByDisplayValue("key1")).toBeInTheDocument();
expect(screen.getByDisplayValue("value1")).toBeInTheDocument();
// The new prop values should not be displayed
expect(screen.queryByDisplayValue("key2")).not.toBeInTheDocument();
expect(screen.queryByDisplayValue("value2")).not.toBeInTheDocument();
});
});
});

View file

@ -913,7 +913,8 @@ describe("Sidebar", () => {
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
description:
"Client-side timeout (ms) - Inspector will cancel requests after this time",
value: 5000,
is_session_item: false,
},
@ -988,7 +989,8 @@ describe("Sidebar", () => {
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
description:
"Client-side timeout (ms) - Inspector will cancel requests after this time",
value: 0,
is_session_item: false,
},
@ -1035,7 +1037,8 @@ describe("Sidebar", () => {
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
description:
"Client-side timeout (ms) - Inspector will cancel requests after this time",
value: 3000,
is_session_item: false,
},

View file

@ -6,6 +6,11 @@ import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { Tabs } from "@/components/ui/tabs";
import { cacheToolOutputSchemas } from "@/utils/schemaUtils";
import { within } from "@testing-library/react";
import {
META_NAME_RULES_MESSAGE,
META_PREFIX_RULES_MESSAGE,
RESERVED_NAMESPACE_MESSAGE,
} from "@/utils/metaUtils";
describe("ToolsTab", () => {
beforeEach(() => {
@ -119,9 +124,13 @@ describe("ToolsTab", () => {
fireEvent.click(submitButton);
});
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
count: 42,
});
expect(defaultProps.callTool).toHaveBeenCalledWith(
mockTools[1].name,
{
count: 42,
},
undefined,
);
});
it("should allow typing negative numbers", async () => {
@ -140,9 +149,13 @@ describe("ToolsTab", () => {
fireEvent.click(submitButton);
});
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, {
num: -42,
});
expect(defaultProps.callTool).toHaveBeenCalledWith(
mockTools[0].name,
{
num: -42,
},
undefined,
);
});
it("should allow specifying null value", async () => {
@ -172,9 +185,117 @@ describe("ToolsTab", () => {
});
// Tool should have been called with null value
expect(mockCallTool).toHaveBeenCalledWith(toolWithNullableField.name, {
num: null,
expect(mockCallTool).toHaveBeenCalledWith(
toolWithNullableField.name,
{
num: null,
},
undefined,
);
});
it("should support tri-state nullable boolean (null -> false -> true -> null)", async () => {
const mockCallTool = jest.fn();
const toolWithNullableBoolean: Tool = {
name: "testTool",
description: "Tool with nullable boolean",
inputSchema: {
type: "object" as const,
properties: {
optionalBoolean: {
type: ["boolean", "null"] as const,
default: null,
},
},
},
};
renderToolsTab({
tools: [toolWithNullableBoolean],
selectedTool: toolWithNullableBoolean,
callTool: mockCallTool,
});
const nullCheckbox = screen.getByRole("checkbox", { name: /null/i });
const runButton = screen.getByRole("button", { name: /run tool/i });
// State 1: Initial state should be null (input disabled)
const wrapper = screen.getByRole("toolinputwrapper");
expect(wrapper.classList).toContain("pointer-events-none");
expect(wrapper.classList).toContain("opacity-50");
// Verify tool is called with null initially
await act(async () => {
fireEvent.click(runButton);
});
expect(mockCallTool).toHaveBeenCalledWith(
toolWithNullableBoolean.name,
{
optionalBoolean: null,
},
undefined,
);
// State 2: Uncheck null checkbox -> should set value to false and enable input
await act(async () => {
fireEvent.click(nullCheckbox);
});
expect(wrapper.classList).not.toContain("pointer-events-none");
// Clear previous calls to make assertions clearer
mockCallTool.mockClear();
// Verify tool can be called with false
await act(async () => {
fireEvent.click(runButton);
});
expect(mockCallTool).toHaveBeenLastCalledWith(
toolWithNullableBoolean.name,
{
optionalBoolean: false,
},
undefined,
);
// State 3: Check boolean checkbox -> should set value to true
// Find the boolean checkbox within the input wrapper (to avoid ID conflict with null checkbox)
const booleanCheckbox = within(wrapper).getByRole("checkbox");
mockCallTool.mockClear();
await act(async () => {
fireEvent.click(booleanCheckbox);
});
// Verify tool can be called with true
await act(async () => {
fireEvent.click(runButton);
});
expect(mockCallTool).toHaveBeenLastCalledWith(
toolWithNullableBoolean.name,
{
optionalBoolean: true,
},
undefined,
);
// State 4: Check null checkbox again -> should set value back to null and disable input
await act(async () => {
fireEvent.click(nullCheckbox);
});
expect(wrapper.classList).toContain("pointer-events-none");
// Verify tool can be called with null again
await act(async () => {
fireEvent.click(runButton);
});
expect(mockCallTool).toHaveBeenLastCalledWith(
toolWithNullableBoolean.name,
{
optionalBoolean: null,
},
undefined,
);
});
it("should disable button and change text while tool is running", async () => {
@ -600,10 +721,10 @@ describe("ToolsTab", () => {
});
});
describe("Meta Display", () => {
const toolWithMeta = {
describe("Metadata Display", () => {
const toolWithMetadata = {
name: "metaTool",
description: "Tool with meta",
description: "Tool with metadata",
inputSchema: {
type: "object" as const,
properties: {
@ -616,10 +737,10 @@ describe("ToolsTab", () => {
},
} as unknown as Tool;
it("should display meta section when tool has _meta", () => {
it("should display metadata section when tool has _meta", () => {
renderToolsTab({
tools: [toolWithMeta],
selectedTool: toolWithMeta,
tools: [toolWithMetadata],
selectedTool: toolWithMetadata,
});
expect(screen.getByText("Meta:")).toBeInTheDocument();
@ -628,10 +749,10 @@ describe("ToolsTab", () => {
).toBeInTheDocument();
});
it("should toggle meta expansion", () => {
it("should toggle metadata schema expansion", () => {
renderToolsTab({
tools: [toolWithMeta],
selectedTool: toolWithMeta,
tools: [toolWithMetadata],
selectedTool: toolWithMetadata,
});
// There might be multiple Expand buttons (Output Schema, Meta). We need the one within Meta section
@ -661,19 +782,88 @@ describe("ToolsTab", () => {
});
});
describe("ToolResults Meta", () => {
it("should display meta information when present in toolResult", () => {
const resultWithMeta = {
describe("Metadata submission", () => {
it("should send metadata values when provided", async () => {
const callToolMock = jest.fn(async () => {});
renderToolsTab({ selectedTool: mockTools[0], callTool: callToolMock });
// Add a metadata key/value pair
const addPairButton = screen.getByRole("button", { name: /add pair/i });
await act(async () => {
fireEvent.click(addPairButton);
});
// Fill key and value
const keyInputs = screen.getAllByLabelText(/key/i);
const valueInputs = screen.getAllByLabelText(/value/i);
expect(keyInputs.length).toBeGreaterThan(0);
expect(valueInputs.length).toBeGreaterThan(0);
await act(async () => {
fireEvent.change(keyInputs[0], { target: { value: "requestId" } });
fireEvent.change(valueInputs[0], { target: { value: "abc123" } });
});
// Run tool
const runButton = screen.getByRole("button", { name: /run tool/i });
await act(async () => {
fireEvent.click(runButton);
});
expect(callToolMock).toHaveBeenCalledTimes(1);
expect(callToolMock).toHaveBeenLastCalledWith(
mockTools[0].name,
expect.any(Object),
{ requestId: "abc123" },
);
});
});
describe("Reserved metadata keys", () => {
test.each`
description | value | message
${"reserved metadata prefix"} | ${"modelcontextprotocol.io/flip"} | ${RESERVED_NAMESPACE_MESSAGE}
${"reserved root without slash"} | ${"modelcontextprotocol.io"} | ${RESERVED_NAMESPACE_MESSAGE}
${"nested modelcontextprotocol domain"} | ${"api.modelcontextprotocol.org"} | ${RESERVED_NAMESPACE_MESSAGE}
${"nested mcp domain"} | ${"tools.mcp.com/resource"} | ${RESERVED_NAMESPACE_MESSAGE}
${"invalid name segment"} | ${"custom/bad-"} | ${META_NAME_RULES_MESSAGE}
${"invalid prefix label"} | ${"1invalid-prefix/value"} | ${META_PREFIX_RULES_MESSAGE}
`(
"should block execution when $description is provided",
async ({ value, message }) => {
renderToolsTab({ selectedTool: mockTools[0] });
const addPairButton = screen.getByRole("button", { name: /add pair/i });
await act(async () => {
fireEvent.click(addPairButton);
});
const keyInput = screen.getByPlaceholderText("e.g. requestId");
await act(async () => {
fireEvent.change(keyInput, { target: { value } });
});
const runButton = screen.getByRole("button", { name: /run tool/i });
expect(runButton).toBeDisabled();
expect(screen.getByText(message)).toBeInTheDocument();
},
);
});
describe("ToolResults Metadata", () => {
it("should display metadata information when present in toolResult", () => {
const resultWithMetadata = {
content: [],
_meta: { info: "details", version: 2 },
};
renderToolsTab({
selectedTool: mockTools[0],
toolResult: resultWithMeta,
toolResult: resultWithMetadata,
});
// Only ToolResults meta should be present since selectedTool has no _meta
// Only ToolResults metadata should be present since selectedTool has no _meta
expect(screen.getAllByText("Meta:")).toHaveLength(1);
expect(screen.getByText(/info/i)).toBeInTheDocument();
expect(screen.getByText(/version/i)).toBeInTheDocument();
@ -889,6 +1079,7 @@ describe("ToolsTab", () => {
message: "test message",
count: 5,
},
undefined,
);
});

View file

@ -35,7 +35,8 @@ const buttonVariants = cva(
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}

View file

@ -102,16 +102,40 @@ export const clearClientInformationFromSessionStorage = ({
sessionStorage.removeItem(key);
};
export const getScopeFromSessionStorage = (
serverUrl: string,
): string | undefined => {
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
const value = sessionStorage.getItem(key);
return value || undefined;
};
export const saveScopeToSessionStorage = (
serverUrl: string,
scope: string | undefined,
) => {
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
if (scope) {
sessionStorage.setItem(key, scope);
} else {
sessionStorage.removeItem(key);
}
};
export const clearScopeFromSessionStorage = (serverUrl: string) => {
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
sessionStorage.removeItem(key);
};
export class InspectorOAuthClientProvider implements OAuthClientProvider {
constructor(
protected serverUrl: string,
scope?: string,
) {
this.scope = scope;
constructor(protected serverUrl: string) {
// Save the server URL to session storage
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
}
scope: string | undefined;
get scope(): string | undefined {
return getScopeFromSessionStorage(this.serverUrl);
}
get redirectUrl() {
return window.location.origin + "/oauth/callback";

View file

@ -14,7 +14,8 @@ export type ConfigItem = {
*/
export type InspectorConfig = {
/**
* Maximum time in milliseconds to wait for a response from the MCP server before timing out.
* Client-side timeout in milliseconds. The Inspector will cancel the request if no response
* is received within this time. Note: This is independent of any server-side timeouts.
*/
MCP_SERVER_REQUEST_TIMEOUT: ConfigItem;

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