Merge branch 'main' into fix/enum-anyof-ref-resolution
This commit is contained in:
commit
442638f4c1
32 changed files with 16245 additions and 8744 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
239
client/src/__tests__/App.samplingNavigation.test.tsx
Normal file
239
client/src/__tests__/App.samplingNavigation.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
58
client/src/components/IconDisplay.tsx
Normal file
58
client/src/components/IconDisplay.tsx
Normal 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
170
client/src/components/MetadataTab.tsx
Normal file
170
client/src/components/MetadataTab.tsx
Normal 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;
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import SamplingRequest from "./SamplingRequest";
|
|||
export type PendingRequest = {
|
||||
id: number;
|
||||
request: CreateMessageRequest;
|
||||
originatingTab?: string;
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
229
client/src/components/TasksTab.tsx
Normal file
229
client/src/components/TasksTab.tsx
Normal 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;
|
||||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
593
client/src/components/__tests__/MetadataTab.test.tsx
Normal file
593
client/src/components/__tests__/MetadataTab.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue