Merge branch 'main' into fix/enum-anyof-ref-resolution

This commit is contained in:
Ola Hungerford 2026-02-04 05:41:56 -07:00 • committed by GitHub
commit 442638f4c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 16245 additions and 8744 deletions

View file

@ -16,9 +16,20 @@ import {
ServerNotification,
Tool,
LoggingLevel,
Task,
GetTaskResultSchema,
} 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";
@ -46,7 +57,9 @@ import {
Hammer,
Hash,
Key,
ListTodo,
MessageSquare,
Settings,
} from "lucide-react";
import { z } from "zod";
@ -61,6 +74,7 @@ import RootsTab from "./components/RootsTab";
import SamplingTab, { PendingRequest } from "./components/SamplingTab";
import Sidebar from "./components/Sidebar";
import ToolsTab from "./components/ToolsTab";
import TasksTab from "./components/TasksTab";
import { InspectorConfig } from "./lib/configurationTypes";
import {
getMCPProxyAddress,
@ -71,6 +85,7 @@ import {
getInitialArgs,
initializeInspectorConfig,
saveInspectorConfig,
getMCPTaskTtl,
} from "./utils/configUtils";
import ElicitationTab, {
PendingElicitationRequest,
@ -80,9 +95,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<
@ -95,12 +129,14 @@ const App = () => {
const [prompts, setPrompts] = useState<Prompt[]>([]);
const [promptContent, setPromptContent] = useState<string>("");
const [tools, setTools] = useState<Tool[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [toolResult, setToolResult] =
useState<CompatibilityCallToolResult | null>(null);
const [errors, setErrors] = useState<Record<string, string | null>>({
resources: null,
prompts: null,
tools: null,
tasks: null,
});
const [command, setCommand] = useState<string>(getInitialCommand);
const [args, setArgs] = useState<string>(getInitialArgs);
@ -199,9 +235,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[]>([]);
@ -214,6 +272,8 @@ const App = () => {
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [isPollingTask, setIsPollingTask] = useState(false);
const [nextResourceCursor, setNextResourceCursor] = useState<
string | undefined
>();
@ -224,6 +284,7 @@ const App = () => {
string | undefined
>();
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
const [nextTaskCursor, setNextTaskCursor] = useState<string | undefined>();
const progressTokenRef = useRef(0);
const [activeTab, setActiveTab] = useState<string>(() => {
@ -239,6 +300,32 @@ const App = () => {
currentTabRef.current = activeTab;
}, [activeTab]);
const navigateToOriginatingTab = (originatingTab?: string) => {
if (!originatingTab) return;
const validTabs = [
...(serverCapabilities?.resources ? ["resources"] : []),
...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []),
...(serverCapabilities?.tasks ? ["tasks"] : []),
"ping",
"sampling",
"elicitations",
"roots",
"auth",
];
if (!validTabs.includes(originatingTab)) return;
setActiveTab(originatingTab);
window.location.hash = originatingTab;
setTimeout(() => {
setActiveTab(originatingTab);
window.location.hash = originatingTab;
}, 100);
};
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);
const {
width: sidebarWidth,
@ -246,13 +333,21 @@ const App = () => {
handleDragStart: handleSidebarDragStart,
} = useDraggableSidebar(320);
const selectedTaskRef = useRef<Task | null>(null);
useEffect(() => {
selectedTaskRef.current = selectedTask;
}, [selectedTask]);
const {
connectionStatus,
serverCapabilities,
serverImplementation,
mcpClient,
requestHistory,
clearRequestHistory,
makeRequest,
cancelTask: cancelMcpTask,
listTasks: listMcpTasks,
sendNotification,
handleCompletion,
completionsSupported,
@ -272,12 +367,41 @@ const App = () => {
connectionType,
onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]);
if (notification.method === "notifications/tasks/list_changed") {
void listTasks();
}
if (notification.method === "notifications/tasks/status") {
const task = notification.params as unknown as Task;
setTasks((prev) => {
const exists = prev.some((t) => t.taskId === task.taskId);
if (exists) {
return prev.map((t) => (t.taskId === task.taskId ? task : t));
} else {
return [task, ...prev];
}
});
if (selectedTaskRef.current?.taskId === task.taskId) {
setSelectedTask(task);
}
}
},
onPendingRequest: (request, resolve, reject) => {
const currentTab = lastToolCallOriginTabRef.current;
setPendingSampleRequests((prev) => [
...prev,
{ id: nextRequestId.current++, request, resolve, reject },
{
id: nextRequestId.current++,
request,
originatingTab: currentTab,
resolve,
reject,
},
]);
setActiveTab("sampling");
window.location.hash = "sampling";
},
onElicitationRequest: (request, resolve) => {
const currentTab = lastToolCallOriginTabRef.current;
@ -304,6 +428,7 @@ const App = () => {
},
getRoots: () => rootsRef.current,
defaultLoggingLevel: logLevel,
metadata,
});
useEffect(() => {
@ -314,6 +439,7 @@ const App = () => {
...(serverCapabilities?.resources ? ["resources"] : []),
...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []),
...(serverCapabilities?.tasks ? ["tasks"] : []),
"ping",
"sampling",
"elicitations",
@ -330,7 +456,9 @@ const App = () => {
? "prompts"
: serverCapabilities?.tools
? "tools"
: "ping";
: serverCapabilities?.tasks
? "tasks"
: "ping";
setActiveTab(defaultTab);
window.location.hash = defaultTab;
@ -338,6 +466,13 @@ const App = () => {
}
}, [serverCapabilities]);
useEffect(() => {
if (mcpClient && activeTab === "tasks") {
void listTasks();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mcpClient, activeTab]);
useEffect(() => {
localStorage.setItem("lastCommand", command);
}, [command]);
@ -557,7 +692,9 @@ const App = () => {
? "prompts"
: serverCapabilities?.tools
? "tools"
: "ping";
: serverCapabilities?.tasks
? "tasks"
: "ping";
window.location.hash = defaultTab;
} else if (!mcpClient && window.location.hash) {
// Clear hash when disconnected - completely remove the fragment
@ -585,6 +722,9 @@ const App = () => {
setPendingSampleRequests((prev) => {
const request = prev.find((r) => r.id === id);
request?.resolve(result);
navigateToOriginatingTab(request?.originatingTab);
return prev.filter((r) => r.id !== id);
});
};
@ -593,6 +733,9 @@ const App = () => {
setPendingSampleRequests((prev) => {
const request = prev.find((r) => r.id === id);
request?.reject(new Error("Sampling request rejected"));
navigateToOriginatingTab(request?.originatingTab);
return prev.filter((r) => r.id !== id);
});
};
@ -613,6 +756,7 @@ const App = () => {
...(serverCapabilities?.resources ? ["resources"] : []),
...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []),
...(serverCapabilities?.tasks ? ["tasks"] : []),
"ping",
"sampling",
"elicitations",
@ -639,11 +783,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 +928,12 @@ 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>,
runAsTask?: boolean,
) => {
lastToolCallOriginTabRef.current = currentTabRef.current;
try {
@ -794,22 +943,172 @@ const App = () => {
? cleanParams(params, tool.inputSchema as JsonSchemaType)
: params;
const response = await sendMCPRequest(
{
method: "tools/call" as const,
params: {
name,
arguments: cleanedParams,
_meta: {
progressToken: progressTokenRef.current++,
},
},
// 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 request: ClientRequest = {
method: "tools/call" as const,
params: {
name,
arguments: cleanedParams,
_meta: mergedMetadata,
},
};
if (runAsTask) {
request.params = {
...request.params,
task: {
ttl: getMCPTaskTtl(config),
},
};
}
const response = await sendMCPRequest(
request,
CompatibilityCallToolResultSchema,
"tools",
);
setToolResult(response);
// Check if this was a task-augmented request that returned a task reference
// The server returns { task: { taskId, status, ... } } when a task is created
const isTaskResult = (
res: unknown,
): res is {
task: { taskId: string; status: string; pollInterval: number };
} =>
!!res &&
typeof res === "object" &&
"task" in res &&
!!res.task &&
typeof res.task === "object" &&
"taskId" in res.task;
if (runAsTask && isTaskResult(response)) {
const taskId = response.task.taskId;
const pollInterval = response.task.pollInterval;
// Set polling state BEFORE setting tool result for proper UI update
setIsPollingTask(true);
// Safely extract any _meta from the original response (if present)
const initialResponseMeta =
response &&
typeof response === "object" &&
"_meta" in (response as Record<string, unknown>)
? ((response as { _meta?: Record<string, unknown> })._meta ?? {})
: undefined;
setToolResult({
content: [
{
type: "text",
text: `Task created: ${taskId}. Polling for status...`,
},
],
_meta: {
...(initialResponseMeta || {}),
"io.modelcontextprotocol/related-task": { taskId },
},
} as CompatibilityCallToolResult);
// Polling loop
let taskCompleted = false;
while (!taskCompleted) {
try {
// Wait for 1 second before polling
await new Promise((resolve) => setTimeout(resolve, pollInterval));
const taskStatus = await sendMCPRequest(
{
method: "tasks/get",
params: { taskId },
},
GetTaskResultSchema,
);
if (
taskStatus.status === "completed" ||
taskStatus.status === "failed" ||
taskStatus.status === "cancelled"
) {
taskCompleted = true;
console.log(
`Polling complete for task ${taskId}: ${taskStatus.status}`,
);
if (taskStatus.status === "completed") {
console.log(`Fetching result for task ${taskId}`);
const result = await sendMCPRequest(
{
method: "tasks/result",
params: { taskId },
},
CompatibilityCallToolResultSchema,
);
console.log(`Result received for task ${taskId}:`, result);
setToolResult(result as CompatibilityCallToolResult);
// Refresh tasks list to show completed state
void listTasks();
} else {
setToolResult({
content: [
{
type: "text",
text: `Task ${taskStatus.status}: ${taskStatus.statusMessage || "No additional information"}`,
},
],
isError: true,
});
// Refresh tasks list to show failed/cancelled state
void listTasks();
}
} else {
// Update status message while polling
// Safely extract any _meta from the original response (if present)
const pollingResponseMeta =
response &&
typeof response === "object" &&
"_meta" in (response as Record<string, unknown>)
? ((response as { _meta?: Record<string, unknown> })._meta ??
{})
: undefined;
setToolResult({
content: [
{
type: "text",
text: `Task status: ${taskStatus.status}${taskStatus.statusMessage ? ` - ${taskStatus.statusMessage}` : ""}. Polling...`,
},
],
_meta: {
...(pollingResponseMeta || {}),
"io.modelcontextprotocol/related-task": { taskId },
},
} as CompatibilityCallToolResult);
// Refresh tasks list to show progress
void listTasks();
}
} catch (pollingError) {
console.error("Error polling task status:", pollingError);
setToolResult({
content: [
{
type: "text",
text: `Error polling task status: ${pollingError instanceof Error ? pollingError.message : String(pollingError)}`,
},
],
isError: true,
});
taskCompleted = true;
}
}
setIsPollingTask(false);
} else {
setToolResult(response as CompatibilityCallToolResult);
}
// Clear any validation errors since tool execution completed
setErrors((prev) => ({ ...prev, tools: null }));
} catch (e) {
@ -828,6 +1127,37 @@ const App = () => {
}
};
const listTasks = useCallback(async () => {
try {
const response = await listMcpTasks(nextTaskCursor);
setTasks(response.tasks);
setNextTaskCursor(response.nextCursor);
// Inline error clear to avoid extra dependency on clearError
setErrors((prev) => ({ ...prev, tasks: null }));
} catch (e) {
setErrors((prev) => ({
...prev,
tasks: (e as Error).message ?? String(e),
}));
}
}, [listMcpTasks, nextTaskCursor]);
const cancelTask = async (taskId: string) => {
try {
const response = await cancelMcpTask(taskId);
setTasks((prev) => prev.map((t) => (t.taskId === taskId ? response : t)));
if (selectedTask?.taskId === taskId) {
setSelectedTask(response);
}
clearError("tasks");
} catch (e) {
setErrors((prev) => ({
...prev,
tasks: (e as Error).message ?? String(e),
}));
}
};
const handleRootsChange = async () => {
await sendNotification({ method: "notifications/roots/list_changed" });
};
@ -920,6 +1250,7 @@ const App = () => {
loggingSupported={!!serverCapabilities?.logging || false}
connectionType={connectionType}
setConnectionType={setConnectionType}
serverImplementation={serverImplementation}
/>
<div
onMouseDown={handleSidebarDragStart}
@ -970,6 +1301,13 @@ const App = () => {
<Hammer className="w-4 h-4 mr-2" />
Tools
</TabsTrigger>
<TabsTrigger
value="tasks"
disabled={!serverCapabilities?.tasks}
>
<ListTodo className="w-4 h-4 mr-2" />
Tasks
</TabsTrigger>
<TabsTrigger value="ping">
<Bell className="w-4 h-4 mr-2" />
Ping
@ -1000,6 +1338,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 +1452,15 @@ const App = () => {
setNextToolCursor(undefined);
cacheToolOutputSchemas([]);
}}
callTool={async (name, params) => {
callTool={async (
name: string,
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
runAsTask?: boolean,
) => {
clearError("tools");
setToolResult(null);
await callTool(name, params);
await callTool(name, params, metadata, runAsTask);
}}
selectedTool={selectedTool}
setSelectedTool={(tool) => {
@ -1122,6 +1469,7 @@ const App = () => {
setToolResult(null);
}}
toolResult={toolResult}
isPollingTask={isPollingTask}
nextCursor={nextToolCursor}
error={errors.tools}
resourceContent={resourceContentMap}
@ -1130,6 +1478,25 @@ const App = () => {
readResource(uri);
}}
/>
<TasksTab
tasks={tasks}
listTasks={() => {
clearError("tasks");
listTasks();
}}
clearTasks={() => {
setTasks([]);
setNextTaskCursor(undefined);
}}
cancelTask={cancelTask}
selectedTask={selectedTask}
setSelectedTask={(task) => {
clearError("tasks");
setSelectedTask(task);
}}
error={errors.tasks}
nextCursor={nextTaskCursor}
/>
<ConsoleTab />
<PingTab
onPingClick={() => {
@ -1156,6 +1523,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

@ -0,0 +1,239 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import App from "../App";
import { useConnection } from "../lib/hooks/useConnection";
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type {
CreateMessageRequest,
CreateMessageResult,
} from "@modelcontextprotocol/sdk/types.js";
type OnPendingRequestHandler = (
request: CreateMessageRequest,
resolve: (result: CreateMessageResult) => void,
reject: (error: Error) => void,
) => void;
type SamplingRequestMockProps = {
request: { id: number };
onApprove: (id: number, result: CreateMessageResult) => void;
onReject: (id: number) => void;
};
type UseConnectionReturn = ReturnType<typeof useConnection>;
// Mock auth dependencies first
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
auth: jest.fn(),
}));
jest.mock("../lib/oauth-state-machine", () => ({
OAuthStateMachine: jest.fn(),
}));
jest.mock("../lib/auth", () => ({
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
tokens: jest.fn().mockResolvedValue(null),
clear: jest.fn(),
})),
DebugInspectorOAuthClientProvider: jest.fn(),
}));
jest.mock("../utils/configUtils", () => ({
...jest.requireActual("../utils/configUtils"),
getMCPProxyAddress: jest.fn(() => "http://localhost:6277"),
getMCPProxyAuthToken: jest.fn(() => ({
token: "",
header: "X-MCP-Proxy-Auth",
})),
getInitialTransportType: jest.fn(() => "stdio"),
getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"),
getInitialCommand: jest.fn(() => "mcp-server-everything"),
getInitialArgs: jest.fn(() => ""),
initializeInspectorConfig: jest.fn(() => ({})),
saveInspectorConfig: jest.fn(),
}));
jest.mock("../lib/hooks/useDraggablePane", () => ({
useDraggablePane: () => ({
height: 300,
handleDragStart: jest.fn(),
}),
useDraggableSidebar: () => ({
width: 320,
isDragging: false,
handleDragStart: jest.fn(),
}),
}));
jest.mock("../components/Sidebar", () => ({
__esModule: true,
default: () => <div>Sidebar</div>,
}));
jest.mock("../lib/hooks/useToast", () => ({
useToast: () => ({ toast: jest.fn() }),
}));
// Keep the test focused on navigation; avoid DynamicJsonForm/schema complexity.
jest.mock("../components/SamplingRequest", () => ({
__esModule: true,
default: ({ request, onApprove, onReject }: SamplingRequestMockProps) => (
<div data-testid="sampling-request">
<div>sampling-request-{request.id}</div>
<button
type="button"
onClick={() =>
onApprove(request.id, {
model: "stub-model",
stopReason: "endTurn",
role: "assistant",
content: { type: "text", text: "" },
})
}
>
Approve
</button>
<button type="button" onClick={() => onReject(request.id)}>
Reject
</button>
</div>
),
}));
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) });
jest.mock("../lib/hooks/useConnection", () => ({
useConnection: jest.fn(),
}));
describe("App - Sampling auto-navigation", () => {
const mockUseConnection = jest.mocked(useConnection);
const baseConnectionState = {
connectionStatus: "connected" as const,
serverCapabilities: { tools: { listChanged: true, subscribe: true } },
mcpClient: {
request: jest.fn(),
notification: jest.fn(),
close: jest.fn(),
} as unknown as Client,
requestHistory: [],
clearRequestHistory: jest.fn(),
makeRequest: jest.fn(),
sendNotification: jest.fn(),
handleCompletion: jest.fn(),
completionsSupported: false,
connect: jest.fn(),
disconnect: jest.fn(),
serverImplementation: null,
cancelTask: jest.fn(),
listTasks: jest.fn(),
};
beforeEach(() => {
jest.restoreAllMocks();
window.location.hash = "#tools";
});
test("switches to #sampling when a sampling request arrives and switches back to #tools after approve", async () => {
let capturedOnPendingRequest: OnPendingRequestHandler | undefined;
mockUseConnection.mockImplementation((options) => {
capturedOnPendingRequest = (
options as { onPendingRequest?: OnPendingRequestHandler }
).onPendingRequest;
return baseConnectionState as unknown as UseConnectionReturn;
});
render(<App />);
// Ensure we start on tools.
await waitFor(() => {
expect(window.location.hash).toBe("#tools");
});
const resolve = jest.fn();
const reject = jest.fn();
act(() => {
if (!capturedOnPendingRequest) {
throw new Error("Expected onPendingRequest to be provided");
}
capturedOnPendingRequest(
{
method: "sampling/createMessage",
params: { messages: [], maxTokens: 1 },
},
resolve,
reject,
);
});
await waitFor(() => {
expect(window.location.hash).toBe("#sampling");
expect(screen.getByTestId("sampling-request")).toBeTruthy();
});
fireEvent.click(screen.getByText("Approve"));
await waitFor(() => {
expect(resolve).toHaveBeenCalled();
expect(window.location.hash).toBe("#tools");
});
});
test("switches back to #tools after reject", async () => {
let capturedOnPendingRequest: OnPendingRequestHandler | undefined;
mockUseConnection.mockImplementation((options) => {
capturedOnPendingRequest = (
options as { onPendingRequest?: OnPendingRequestHandler }
).onPendingRequest;
return baseConnectionState as unknown as UseConnectionReturn;
});
render(<App />);
await waitFor(() => {
expect(window.location.hash).toBe("#tools");
});
const resolve = jest.fn();
const reject = jest.fn();
act(() => {
if (!capturedOnPendingRequest) {
throw new Error("Expected onPendingRequest to be provided");
}
capturedOnPendingRequest(
{
method: "sampling/createMessage",
params: { messages: [], maxTokens: 1 },
},
resolve,
reject,
);
});
await waitFor(() => {
expect(window.location.hash).toBe("#sampling");
expect(screen.getByTestId("sampling-request")).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: /Reject/i }));
await waitFor(() => {
expect(reject).toHaveBeenCalled();
expect(window.location.hash).toBe("#tools");
});
});
});

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";
@ -24,6 +28,7 @@ interface DynamicJsonFormProps {
export interface DynamicJsonFormRef {
validateJson: () => { isValid: boolean; error: string | null };
hasJsonError: () => boolean;
}
const isTypeSupported = (
@ -76,7 +81,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);
@ -92,6 +130,10 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
// on every keystroke which would be inefficient and error-prone
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const hasJsonError = () => {
return !!jsonError;
};
// Debounce JSON parsing and parent updates to handle typing gracefully
const debouncedUpdateParent = useCallback(
(jsonString: string) => {
@ -216,6 +258,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
useImperativeHandle(ref, () => ({
validateJson,
hasJsonError,
}));
const renderFormFields = (
@ -267,63 +310,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 +474,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 +517,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 +538,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 +763,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

@ -9,6 +9,7 @@ import SamplingRequest from "./SamplingRequest";
export type PendingRequest = {
id: number;
request: CreateMessageRequest;
originatingTab?: string;
};
export type Props = {

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

@ -0,0 +1,229 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { TabsContent } from "@/components/ui/tabs";
import { Task } from "@modelcontextprotocol/sdk/types.js";
import {
AlertCircle,
RefreshCw,
XCircle,
Clock,
CheckCircle2,
AlertTriangle,
PlayCircle,
} from "lucide-react";
import ListPane from "./ListPane";
import { useState } from "react";
import JsonView from "./JsonView";
import { cn } from "@/lib/utils";
const TaskStatusIcon = ({ status }: { status: Task["status"] }) => {
switch (status) {
case "working":
return <Clock className="h-4 w-4 animate-pulse text-blue-500" />;
case "input_required":
return <AlertTriangle className="h-4 w-4 text-yellow-500" />;
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "failed":
return <XCircle className="h-4 w-4 text-red-500" />;
case "cancelled":
return <XCircle className="h-4 w-4 text-gray-500" />;
default:
return <PlayCircle className="h-4 w-4" />;
}
};
const TasksTab = ({
tasks,
listTasks,
clearTasks,
cancelTask,
selectedTask,
setSelectedTask,
error,
nextCursor,
}: {
tasks: Task[];
listTasks: () => void;
clearTasks: () => void;
cancelTask: (taskId: string) => Promise<void>;
selectedTask: Task | null;
setSelectedTask: (task: Task | null) => void;
error: string | null;
nextCursor?: string;
}) => {
const [isCancelling, setIsCancelling] = useState<string | null>(null);
const displayedTask = selectedTask
? tasks.find((t) => t.taskId === selectedTask.taskId) || selectedTask
: null;
const handleCancel = async (taskId: string) => {
setIsCancelling(taskId);
try {
await cancelTask(taskId);
} finally {
setIsCancelling(null);
}
};
return (
<TabsContent value="tasks" className="flex-1 overflow-hidden p-0 m-0">
<div className="flex h-full overflow-hidden p-4 gap-4">
<div className="w-1/3">
<ListPane
title="Tasks"
items={tasks}
setSelectedItem={setSelectedTask}
listItems={listTasks}
clearItems={clearTasks}
buttonText={nextCursor ? "List More Tasks" : "List Tasks"}
isButtonDisabled={!nextCursor && tasks.length > 0}
renderItem={(task) => (
<div className="flex items-center gap-2 overflow-hidden w-full">
<TaskStatusIcon status={task.status} />
<div className="flex flex-col overflow-hidden">
<span className="truncate font-medium">{task.taskId}</span>
<span className="truncate text-xs text-muted-foreground">
{task.status} -{" "}
{new Date(task.lastUpdatedAt).toLocaleString()}
</span>
</div>
</div>
)}
/>
</div>
<div className="flex-1 overflow-y-auto p-4 bg-background border border-border rounded-lg">
{error && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{displayedTask ? (
<div className="space-y-6">
<div className="flex items-center justify-between border-b pb-4">
<div>
<h2 className="text-2xl font-bold tracking-tight">
Task Details
</h2>
<p className="text-muted-foreground">
ID: {displayedTask.taskId}
</p>
</div>
{(displayedTask.status === "working" ||
displayedTask.status === "input_required") && (
<Button
variant="destructive"
size="sm"
aria-label={`Cancel task ${displayedTask.taskId}`}
onClick={() => handleCancel(displayedTask.taskId)}
disabled={isCancelling === displayedTask.taskId}
>
{isCancelling === displayedTask.taskId ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<XCircle className="mr-2 h-4 w-4" />
)}
Cancel Task
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg border p-3">
<p className="text-sm font-medium text-muted-foreground">
Status
</p>
<div className="mt-1 flex items-center gap-2">
<TaskStatusIcon status={displayedTask.status} />
<span
className={cn(
"font-semibold capitalize",
displayedTask.status === "working" && "text-blue-500",
displayedTask.status === "completed" &&
"text-green-500",
displayedTask.status === "failed" && "text-red-500",
displayedTask.status === "cancelled" && "text-gray-500",
displayedTask.status === "input_required" &&
"text-yellow-500",
)}
>
{displayedTask.status.replace("_", " ")}
</span>
</div>
</div>
<div className="rounded-lg border p-3">
<p className="text-sm font-medium text-muted-foreground">
Last Updated
</p>
<p className="mt-1 font-medium">
{new Date(displayedTask.lastUpdatedAt).toLocaleString()}
</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-sm font-medium text-muted-foreground">
Created At
</p>
<p className="mt-1 font-medium">
{new Date(displayedTask.createdAt).toLocaleString()}
</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-sm font-medium text-muted-foreground">
TTL
</p>
<p className="mt-1 font-medium">
{displayedTask.ttl === null
? "Infinite"
: `${displayedTask.ttl}ms`}
</p>
</div>
</div>
{displayedTask.statusMessage && (
<div className="rounded-lg border p-3">
<p className="text-sm font-medium text-muted-foreground">
Status Message
</p>
<p className="mt-1 whitespace-pre-wrap">
{displayedTask.statusMessage}
</p>
</div>
)}
<div className="space-y-2">
<h3 className="text-lg font-semibold">Full Task Object</h3>
<div className="rounded-md border">
<JsonView data={displayedTask} />
</div>
</div>
</div>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
<div className="text-center">
<Clock className="mx-auto mb-4 h-12 w-12 opacity-20" />
<h3 className="text-lg font-medium">No Task Selected</h3>
<p>Select a task from the list to view its details.</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={listTasks}
>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh Tasks
</Button>
</div>
</div>
)}
</div>
</div>
</TabsContent>
);
};
export default TasksTab;

View file

@ -12,6 +12,7 @@ interface ToolResultsProps {
selectedTool: Tool | null;
resourceContent: Record<string, string>;
onReadResource?: (uri: string) => void;
isPollingTask?: boolean;
}
const checkContentCompatibility = (
@ -69,6 +70,7 @@ const ToolResults = ({
selectedTool,
resourceContent,
onReadResource,
isPollingTask,
}: ToolResultsProps) => {
if (!toolResult) return null;
@ -80,8 +82,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} />
))}
</>
);
@ -89,6 +91,19 @@ const ToolResults = ({
const structuredResult = parsedResult.data;
const isError = structuredResult.isError ?? false;
// Check if this is a running task
const relatedTask = structuredResult._meta?.[
"io.modelcontextprotocol/related-task"
] as { taskId: string } | undefined;
const isTaskRunning =
isPollingTask ||
(!!relatedTask &&
structuredResult.content.some(
(c) =>
c.type === "text" &&
(c.text?.includes("Polling") || c.text?.includes("Task status")),
));
let validationResult = null;
const toolHasOutputSchema =
selectedTool && hasOutputSchema(selectedTool.name);
@ -127,6 +142,8 @@ const ToolResults = ({
Tool Result:{" "}
{isError ? (
<span className="text-red-600 font-semibold">Error</span>
) : isTaskRunning ? (
<span className="text-yellow-600 font-semibold">Task Running</span>
) : (
<span className="text-green-600 font-semibold">Success</span>
)}
@ -205,7 +222,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

@ -30,6 +30,7 @@ import {
Send,
ChevronDown,
ChevronUp,
ChevronRight,
AlertCircle,
Copy,
CheckCheck,
@ -40,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 } =>
@ -53,6 +64,7 @@ const ToolsTab = ({
selectedTool,
setSelectedTool,
toolResult,
isPollingTask,
nextCursor,
error,
resourceContent,
@ -61,28 +73,40 @@ 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>,
runAsTask?: boolean,
) => Promise<void>;
selectedTool: Tool | null;
setSelectedTool: (tool: Tool | null) => void;
toolResult: CompatibilityCallToolResult | null;
isPollingTask?: boolean;
nextCursor: ListToolsResult["nextCursor"];
error: string | null;
resourceContent: Record<string, string>;
onReadResource?: (uri: string) => void;
}) => {
const [params, setParams] = useState<Record<string, unknown>>({});
const [runAsTask, setRunAsTask] = useState(false);
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();
const { copied, setCopied } = useCopy();
// Function to check if any form has validation errors
const checkValidationErrors = () => {
const checkValidationErrors = (validateChildren: boolean = false) => {
const errors = Object.values(formRefs.current).some(
(ref) => ref && !ref.validateJson().isValid,
(ref) =>
ref &&
(validateChildren ? !ref.validateJson().isValid : ref.hasJsonError()),
);
setHasValidationErrors(errors);
return errors;
@ -107,6 +131,7 @@ const ToolsTab = ({
];
});
setParams(Object.fromEntries(params));
setRunAsTask(false);
// Reset validation errors when switching tools
setHasValidationErrors(false);
@ -115,6 +140,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">
@ -124,14 +164,21 @@ const ToolsTab = ({
clearItems={() => {
clearTools();
setSelectedTool(null);
setRunAsTask(false);
}}
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"
@ -141,9 +188,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 ? (
@ -398,6 +453,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">
@ -443,10 +630,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
@ -461,40 +650,83 @@ 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;
try {
setIsToolRunning(true);
await callTool(selectedTool.name, params);
} finally {
setIsToolRunning(false);
}
}}
disabled={isToolRunning || hasValidationErrors}
<div className="flex items-center space-x-2">
<Checkbox
id="run-as-task"
checked={runAsTask}
onCheckedChange={(checked: boolean) =>
setRunAsTask(checked)
}
/>
<Label
htmlFor="run-as-task"
className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer"
>
{isToolRunning ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Running...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Run Tool
</>
)}
</Button>
Run as task
</Label>
</div>
<Button
onClick={async () => {
// Validate JSON inputs before calling tool
if (checkValidationErrors(true)) return;
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,
runAsTask,
);
} finally {
setIsToolRunning(false);
}
}}
disabled={
isToolRunning ||
isPollingTask ||
hasValidationErrors ||
hasReservedMetadataEntry ||
hasInvalidMetaPrefixEntry ||
hasInvalidMetaNameEntry
}
>
{isToolRunning || isPollingTask ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{isPollingTask ? "Polling Task..." : "Running..."}
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Run Tool
</>
)}
</Button>
<div className="flex gap-2">
<Button
onClick={async () => {
try {
@ -524,6 +756,7 @@ const ToolsTab = ({
selectedTool={selectedTool}
resourceContent={resourceContent}
onReadResource={onReadResource}
isPollingTask={isPollingTask}
/>
</div>
) : (

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

@ -3,9 +3,14 @@ import "@testing-library/jest-dom";
import { describe, it, jest, beforeEach } from "@jest/globals";
import ToolsTab from "../ToolsTab";
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import { Tabs } from "@/components/ui/tabs";
import { cacheToolOutputSchemas } from "@/utils/schemaUtils";
import { Tabs } from "../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,14 @@ describe("ToolsTab", () => {
fireEvent.click(submitButton);
});
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
count: 42,
});
expect(defaultProps.callTool).toHaveBeenCalledWith(
mockTools[1].name,
{
count: 42,
},
undefined,
false,
);
});
it("should allow typing negative numbers", async () => {
@ -140,9 +150,14 @@ describe("ToolsTab", () => {
fireEvent.click(submitButton);
});
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, {
num: -42,
});
expect(defaultProps.callTool).toHaveBeenCalledWith(
mockTools[0].name,
{
num: -42,
},
undefined,
false,
);
});
it("should allow specifying null value", async () => {
@ -172,9 +187,14 @@ 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,
false,
);
});
it("should support tri-state nullable boolean (null -> false -> true -> null)", async () => {
@ -211,9 +231,14 @@ describe("ToolsTab", () => {
await act(async () => {
fireEvent.click(runButton);
});
expect(mockCallTool).toHaveBeenCalledWith(toolWithNullableBoolean.name, {
optionalBoolean: null,
});
expect(mockCallTool).toHaveBeenCalledWith(
toolWithNullableBoolean.name,
{
optionalBoolean: null,
},
undefined,
false,
);
// State 2: Uncheck null checkbox -> should set value to false and enable input
await act(async () => {
@ -233,6 +258,8 @@ describe("ToolsTab", () => {
{
optionalBoolean: false,
},
undefined,
false,
);
// State 3: Check boolean checkbox -> should set value to true
@ -254,6 +281,8 @@ describe("ToolsTab", () => {
{
optionalBoolean: true,
},
undefined,
false,
);
// State 4: Check null checkbox again -> should set value back to null and disable input
@ -271,6 +300,8 @@ describe("ToolsTab", () => {
{
optionalBoolean: null,
},
undefined,
false,
);
});
@ -697,10 +728,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: {
@ -713,10 +744,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();
@ -725,10 +756,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
@ -758,19 +789,89 @@ 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" },
false,
);
});
});
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();
@ -986,6 +1087,8 @@ describe("ToolsTab", () => {
message: "test message",
count: 5,
},
undefined,
false,
);
});

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;
}

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