Merge remote-tracking branch 'theirs/main' into max/improved-oauth-callback
This commit is contained in:
@@ -1,35 +1,10 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import JsonEditor from "./JsonEditor";
|
||||
import { updateValueAtPath, JsonObject } from "@/utils/jsonPathUtils";
|
||||
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
|
||||
|
||||
export type JsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
export type JsonSchemaType = {
|
||||
type:
|
||||
| "string"
|
||||
| "number"
|
||||
| "integer"
|
||||
| "boolean"
|
||||
| "array"
|
||||
| "object"
|
||||
| "null";
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: JsonValue;
|
||||
properties?: Record<string, JsonSchemaType>;
|
||||
items?: JsonSchemaType;
|
||||
};
|
||||
import { updateValueAtPath } from "@/utils/jsonUtils";
|
||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||
|
||||
interface DynamicJsonFormProps {
|
||||
schema: JsonSchemaType;
|
||||
@@ -38,13 +13,23 @@ interface DynamicJsonFormProps {
|
||||
maxDepth?: number;
|
||||
}
|
||||
|
||||
const isSimpleObject = (schema: JsonSchemaType): boolean => {
|
||||
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
|
||||
if (supportedTypes.includes(schema.type)) return true;
|
||||
if (schema.type !== "object") return false;
|
||||
return Object.values(schema.properties ?? {}).every((prop) =>
|
||||
supportedTypes.includes(prop.type),
|
||||
);
|
||||
};
|
||||
|
||||
const DynamicJsonForm = ({
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
maxDepth = 3,
|
||||
}: DynamicJsonFormProps) => {
|
||||
const [isJsonMode, setIsJsonMode] = useState(false);
|
||||
const isOnlyJSON = !isSimpleObject(schema);
|
||||
const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON);
|
||||
const [jsonError, setJsonError] = useState<string>();
|
||||
// Store the raw JSON string to allow immediate feedback during typing
|
||||
// while deferring parsing until the user stops typing
|
||||
@@ -231,111 +216,6 @@ const DynamicJsonForm = ({
|
||||
required={propSchema.required}
|
||||
/>
|
||||
);
|
||||
case "object": {
|
||||
// Handle case where we have a value but no schema properties
|
||||
const objectValue = (currentValue as JsonObject) || {};
|
||||
|
||||
// If we have schema properties, use them to render fields
|
||||
if (propSchema.properties) {
|
||||
return (
|
||||
<div className="space-y-4 border rounded-md p-4">
|
||||
{Object.entries(propSchema.properties).map(([key, prop]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label>{formatFieldLabel(key)}</Label>
|
||||
{renderFormFields(
|
||||
prop,
|
||||
objectValue[key],
|
||||
[...path, key],
|
||||
depth + 1,
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// If we have a value but no schema properties, render fields based on the value
|
||||
else if (Object.keys(objectValue).length > 0) {
|
||||
return (
|
||||
<div className="space-y-4 border rounded-md p-4">
|
||||
{Object.entries(objectValue).map(([key, value]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label>{formatFieldLabel(key)}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={String(value)}
|
||||
onChange={(e) =>
|
||||
handleFieldChange([...path, key], e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// If we have neither schema properties nor value, return null
|
||||
return null;
|
||||
}
|
||||
case "array": {
|
||||
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
|
||||
if (!propSchema.items) return null;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{propSchema.description && (
|
||||
<p className="text-sm text-gray-600">{propSchema.description}</p>
|
||||
)}
|
||||
|
||||
{propSchema.items?.description && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Items: {propSchema.items.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{arrayValue.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{renderFormFields(
|
||||
propSchema.items as JsonSchemaType,
|
||||
item,
|
||||
[...path, index.toString()],
|
||||
depth + 1,
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newArray = [...arrayValue];
|
||||
newArray.splice(index, 1);
|
||||
handleFieldChange(path, newArray);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const defaultValue = generateDefaultValue(
|
||||
propSchema.items as JsonSchemaType,
|
||||
);
|
||||
handleFieldChange(path, [
|
||||
...arrayValue,
|
||||
defaultValue ?? null,
|
||||
]);
|
||||
}}
|
||||
title={
|
||||
propSchema.items?.description
|
||||
? `Add new ${propSchema.items.description}`
|
||||
: "Add new item"
|
||||
}
|
||||
>
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -374,9 +254,11 @@ const DynamicJsonForm = ({
|
||||
Format JSON
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
|
||||
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
|
||||
</Button>
|
||||
{!isOnlyJSON && (
|
||||
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
|
||||
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isJsonMode ? (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, memo, useMemo, useCallback, useEffect } from "react";
|
||||
import { JsonValue } from "./DynamicJsonForm";
|
||||
import type { JsonValue } from "@/utils/jsonUtils";
|
||||
import clsx from "clsx";
|
||||
import { Copy, CheckCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { getDataType, tryParseJson } from "@/utils/jsonUtils";
|
||||
|
||||
interface JsonViewProps {
|
||||
data: unknown;
|
||||
@@ -11,21 +12,7 @@ interface JsonViewProps {
|
||||
initialExpandDepth?: number;
|
||||
className?: string;
|
||||
withCopyButton?: boolean;
|
||||
}
|
||||
|
||||
function tryParseJson(str: string): { success: boolean; data: JsonValue } {
|
||||
const trimmed = str.trim();
|
||||
if (
|
||||
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
|
||||
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
||||
) {
|
||||
return { success: false, data: str };
|
||||
}
|
||||
try {
|
||||
return { success: true, data: JSON.parse(str) };
|
||||
} catch {
|
||||
return { success: false, data: str };
|
||||
}
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
const JsonView = memo(
|
||||
@@ -35,6 +22,7 @@ const JsonView = memo(
|
||||
initialExpandDepth = 3,
|
||||
className,
|
||||
withCopyButton = true,
|
||||
isError = false,
|
||||
}: JsonViewProps) => {
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -100,6 +88,7 @@ const JsonView = memo(
|
||||
name={name}
|
||||
depth={0}
|
||||
initialExpandDepth={initialExpandDepth}
|
||||
isError={isError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,28 +103,28 @@ interface JsonNodeProps {
|
||||
name?: string;
|
||||
depth: number;
|
||||
initialExpandDepth: number;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
const JsonNode = memo(
|
||||
({ data, name, depth = 0, initialExpandDepth }: JsonNodeProps) => {
|
||||
({
|
||||
data,
|
||||
name,
|
||||
depth = 0,
|
||||
initialExpandDepth,
|
||||
isError = false,
|
||||
}: JsonNodeProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
|
||||
|
||||
const getDataType = (value: JsonValue): string => {
|
||||
if (Array.isArray(value)) return "array";
|
||||
if (value === null) return "null";
|
||||
return typeof value;
|
||||
};
|
||||
|
||||
const dataType = getDataType(data);
|
||||
|
||||
const typeStyleMap: Record<string, string> = {
|
||||
const [typeStyleMap] = useState<Record<string, string>>({
|
||||
number: "text-blue-600",
|
||||
boolean: "text-amber-600",
|
||||
null: "text-purple-600",
|
||||
undefined: "text-gray-600",
|
||||
string: "text-green-600 break-all whitespace-pre-wrap",
|
||||
string: "text-green-600 group-hover:text-green-500",
|
||||
error: "text-red-600 group-hover:text-red-500",
|
||||
default: "text-gray-700",
|
||||
};
|
||||
});
|
||||
const dataType = getDataType(data);
|
||||
|
||||
const renderCollapsible = (isArray: boolean) => {
|
||||
const items = isArray
|
||||
@@ -236,7 +225,14 @@ const JsonNode = memo(
|
||||
{name}:
|
||||
</span>
|
||||
)}
|
||||
<pre className={typeStyleMap.string}>"{value}"</pre>
|
||||
<pre
|
||||
className={clsx(
|
||||
isError ? typeStyleMap.error : typeStyleMap.string,
|
||||
"break-all whitespace-pre-wrap",
|
||||
)}
|
||||
>
|
||||
"{value}"
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -250,8 +246,8 @@ const JsonNode = memo(
|
||||
)}
|
||||
<pre
|
||||
className={clsx(
|
||||
typeStyleMap.string,
|
||||
"cursor-pointer group-hover:text-green-500",
|
||||
isError ? typeStyleMap.error : typeStyleMap.string,
|
||||
"cursor-pointer break-all whitespace-pre-wrap",
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
title={isExpanded ? "Click to collapse" : "Click to expand"}
|
||||
|
||||
@@ -22,7 +22,7 @@ const ListPane = <T extends object>({
|
||||
isButtonDisabled,
|
||||
}: ListPaneProps<T>) => (
|
||||
<div className="bg-card rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="font-semibold dark:text-white">{title}</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { authProvider } from "../lib/auth";
|
||||
import { InspectorOAuthClientProvider } from "../lib/auth";
|
||||
import { SESSION_KEYS } from "../lib/constants";
|
||||
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { useToast } from "@/hooks/use-toast.ts";
|
||||
@@ -43,7 +43,10 @@ const OAuthCallback = ({ onConnect }: OAuthCallbackProps) => {
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await auth(authProvider, {
|
||||
// Create an auth provider with the current server URL
|
||||
const serverAuthProvider = new InspectorOAuthClientProvider(serverUrl);
|
||||
|
||||
result = await auth(serverAuthProvider, {
|
||||
serverUrl,
|
||||
authorizationCode: params.code,
|
||||
});
|
||||
|
||||
@@ -3,14 +3,16 @@ import { Button } from "@/components/ui/button";
|
||||
|
||||
const PingTab = ({ onPingClick }: { onPingClick: () => void }) => {
|
||||
return (
|
||||
<TabsContent value="ping" className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 flex justify-center items-center">
|
||||
<Button
|
||||
onClick={onPingClick}
|
||||
className="font-bold py-6 px-12 rounded-full"
|
||||
>
|
||||
Ping Server
|
||||
</Button>
|
||||
<TabsContent value="ping">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 flex justify-center items-center">
|
||||
<Button
|
||||
onClick={onPingClick}
|
||||
className="font-bold py-6 px-12 rounded-full"
|
||||
>
|
||||
Ping Server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
|
||||
@@ -43,7 +43,7 @@ const PromptsTab = ({
|
||||
clearPrompts: () => void;
|
||||
getPrompt: (name: string, args: Record<string, string>) => void;
|
||||
selectedPrompt: Prompt | null;
|
||||
setSelectedPrompt: (prompt: Prompt) => void;
|
||||
setSelectedPrompt: (prompt: Prompt | null) => void;
|
||||
handleCompletion: (
|
||||
ref: PromptReference | ResourceReference,
|
||||
argName: string,
|
||||
@@ -84,84 +84,91 @@ const PromptsTab = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<TabsContent value="prompts" className="grid grid-cols-2 gap-4">
|
||||
<ListPane
|
||||
items={prompts}
|
||||
listItems={listPrompts}
|
||||
clearItems={clearPrompts}
|
||||
setSelectedItem={(prompt) => {
|
||||
setSelectedPrompt(prompt);
|
||||
setPromptArgs({});
|
||||
}}
|
||||
renderItem={(prompt) => (
|
||||
<>
|
||||
<span className="flex-1">{prompt.name}</span>
|
||||
<span className="text-sm text-gray-500">{prompt.description}</span>
|
||||
</>
|
||||
)}
|
||||
title="Prompts"
|
||||
buttonText={nextCursor ? "List More Prompts" : "List Prompts"}
|
||||
isButtonDisabled={!nextCursor && prompts.length > 0}
|
||||
/>
|
||||
|
||||
<div className="bg-card rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h3 className="font-semibold">
|
||||
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : selectedPrompt ? (
|
||||
<div className="space-y-4">
|
||||
{selectedPrompt.description && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{selectedPrompt.description}
|
||||
</p>
|
||||
)}
|
||||
{selectedPrompt.arguments?.map((arg) => (
|
||||
<div key={arg.name}>
|
||||
<Label htmlFor={arg.name}>{arg.name}</Label>
|
||||
<Combobox
|
||||
id={arg.name}
|
||||
placeholder={`Enter ${arg.name}`}
|
||||
value={promptArgs[arg.name] || ""}
|
||||
onChange={(value) => handleInputChange(arg.name, value)}
|
||||
onInputChange={(value) =>
|
||||
handleInputChange(arg.name, value)
|
||||
}
|
||||
options={completions[arg.name] || []}
|
||||
/>
|
||||
|
||||
{arg.description && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{arg.description}
|
||||
{arg.required && (
|
||||
<span className="text-xs mt-1 ml-1">(Required)</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button onClick={handleGetPrompt} className="w-full">
|
||||
Get Prompt
|
||||
</Button>
|
||||
{promptContent && (
|
||||
<JsonView data={promptContent} withCopyButton={false} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Select a prompt from the list to view and use it
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<TabsContent value="prompts">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ListPane
|
||||
items={prompts}
|
||||
listItems={listPrompts}
|
||||
clearItems={() => {
|
||||
clearPrompts();
|
||||
setSelectedPrompt(null);
|
||||
}}
|
||||
setSelectedItem={(prompt) => {
|
||||
setSelectedPrompt(prompt);
|
||||
setPromptArgs({});
|
||||
}}
|
||||
renderItem={(prompt) => (
|
||||
<>
|
||||
<span className="flex-1">{prompt.name}</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{prompt.description}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
title="Prompts"
|
||||
buttonText={nextCursor ? "List More Prompts" : "List Prompts"}
|
||||
isButtonDisabled={!nextCursor && prompts.length > 0}
|
||||
/>
|
||||
|
||||
<div className="bg-card rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="font-semibold">
|
||||
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : selectedPrompt ? (
|
||||
<div className="space-y-4">
|
||||
{selectedPrompt.description && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{selectedPrompt.description}
|
||||
</p>
|
||||
)}
|
||||
{selectedPrompt.arguments?.map((arg) => (
|
||||
<div key={arg.name}>
|
||||
<Label htmlFor={arg.name}>{arg.name}</Label>
|
||||
<Combobox
|
||||
id={arg.name}
|
||||
placeholder={`Enter ${arg.name}`}
|
||||
value={promptArgs[arg.name] || ""}
|
||||
onChange={(value) => handleInputChange(arg.name, value)}
|
||||
onInputChange={(value) =>
|
||||
handleInputChange(arg.name, value)
|
||||
}
|
||||
options={completions[arg.name] || []}
|
||||
/>
|
||||
|
||||
{arg.description && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{arg.description}
|
||||
{arg.required && (
|
||||
<span className="text-xs mt-1 ml-1">(Required)</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button onClick={handleGetPrompt} className="w-full">
|
||||
Get Prompt
|
||||
</Button>
|
||||
{promptContent && (
|
||||
<JsonView data={promptContent} withCopyButton={false} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Select a prompt from the list to view and use it
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -104,162 +104,177 @@ const ResourcesTab = ({
|
||||
if (selectedTemplate) {
|
||||
const uri = fillTemplate(selectedTemplate.uriTemplate, templateValues);
|
||||
readResource(uri);
|
||||
setSelectedTemplate(null);
|
||||
// We don't have the full Resource object here, so we create a partial one
|
||||
setSelectedResource({ uri, name: uri } as Resource);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TabsContent value="resources" className="grid grid-cols-3 gap-4">
|
||||
<ListPane
|
||||
items={resources}
|
||||
listItems={listResources}
|
||||
clearItems={clearResources}
|
||||
setSelectedItem={(resource) => {
|
||||
setSelectedResource(resource);
|
||||
readResource(resource.uri);
|
||||
setSelectedTemplate(null);
|
||||
}}
|
||||
renderItem={(resource) => (
|
||||
<div className="flex items-center w-full">
|
||||
<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>
|
||||
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
title="Resources"
|
||||
buttonText={nextCursor ? "List More Resources" : "List Resources"}
|
||||
isButtonDisabled={!nextCursor && resources.length > 0}
|
||||
/>
|
||||
|
||||
<ListPane
|
||||
items={resourceTemplates}
|
||||
listItems={listResourceTemplates}
|
||||
clearItems={clearResourceTemplates}
|
||||
setSelectedItem={(template) => {
|
||||
setSelectedTemplate(template);
|
||||
setSelectedResource(null);
|
||||
setTemplateValues({});
|
||||
}}
|
||||
renderItem={(template) => (
|
||||
<div className="flex items-center w-full">
|
||||
<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>
|
||||
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
title="Resource Templates"
|
||||
buttonText={
|
||||
nextTemplateCursor ? "List More Templates" : "List Templates"
|
||||
}
|
||||
isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0}
|
||||
/>
|
||||
|
||||
<div className="bg-card rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200 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>
|
||||
{selectedResource && (
|
||||
<div className="flex row-auto gap-1 justify-end w-2/5">
|
||||
{resourceSubscriptionsSupported &&
|
||||
!resourceSubscriptions.has(selectedResource.uri) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => subscribeToResource(selectedResource.uri)}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
)}
|
||||
{resourceSubscriptionsSupported &&
|
||||
resourceSubscriptions.has(selectedResource.uri) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
unsubscribeFromResource(selectedResource.uri)
|
||||
}
|
||||
>
|
||||
Unsubscribe
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => readResource(selectedResource.uri)}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<TabsContent value="resources">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<ListPane
|
||||
items={resources}
|
||||
listItems={listResources}
|
||||
clearItems={() => {
|
||||
clearResources();
|
||||
// Condition to check if selected resource is not resource template's resource
|
||||
if (!selectedTemplate) {
|
||||
setSelectedResource(null);
|
||||
}
|
||||
}}
|
||||
setSelectedItem={(resource) => {
|
||||
setSelectedResource(resource);
|
||||
readResource(resource.uri);
|
||||
setSelectedTemplate(null);
|
||||
}}
|
||||
renderItem={(resource) => (
|
||||
<div className="flex items-center w-full">
|
||||
<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>
|
||||
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : selectedResource ? (
|
||||
<JsonView
|
||||
data={resourceContent}
|
||||
className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
) : selectedTemplate ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
{selectedTemplate.description}
|
||||
</p>
|
||||
{selectedTemplate.uriTemplate
|
||||
.match(/{([^}]+)}/g)
|
||||
?.map((param) => {
|
||||
const key = param.slice(1, -1);
|
||||
return (
|
||||
<div key={key}>
|
||||
<Label htmlFor={key}>{key}</Label>
|
||||
<Combobox
|
||||
id={key}
|
||||
placeholder={`Enter ${key}`}
|
||||
value={templateValues[key] || ""}
|
||||
onChange={(value) =>
|
||||
handleTemplateValueChange(key, value)
|
||||
}
|
||||
onInputChange={(value) =>
|
||||
handleTemplateValueChange(key, value)
|
||||
}
|
||||
options={completions[key] || []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
onClick={handleReadTemplateResource}
|
||||
disabled={Object.keys(templateValues).length === 0}
|
||||
>
|
||||
Read Resource
|
||||
</Button>
|
||||
title="Resources"
|
||||
buttonText={nextCursor ? "List More Resources" : "List Resources"}
|
||||
isButtonDisabled={!nextCursor && resources.length > 0}
|
||||
/>
|
||||
|
||||
<ListPane
|
||||
items={resourceTemplates}
|
||||
listItems={listResourceTemplates}
|
||||
clearItems={() => {
|
||||
clearResourceTemplates();
|
||||
// Condition to check if selected resource is resource template's resource
|
||||
if (selectedTemplate) {
|
||||
setSelectedResource(null);
|
||||
}
|
||||
setSelectedTemplate(null);
|
||||
}}
|
||||
setSelectedItem={(template) => {
|
||||
setSelectedTemplate(template);
|
||||
setSelectedResource(null);
|
||||
setTemplateValues({});
|
||||
}}
|
||||
renderItem={(template) => (
|
||||
<div className="flex items-center w-full">
|
||||
<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>
|
||||
<ChevronRight className="w-4 h-4 flex-shrink-0 text-gray-400" />
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Select a resource or template from the list to view its contents
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
title="Resource Templates"
|
||||
buttonText={
|
||||
nextTemplateCursor ? "List More Templates" : "List Templates"
|
||||
}
|
||||
isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0}
|
||||
/>
|
||||
|
||||
<div className="bg-card rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800 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>
|
||||
{selectedResource && (
|
||||
<div className="flex row-auto gap-1 justify-end w-2/5">
|
||||
{resourceSubscriptionsSupported &&
|
||||
!resourceSubscriptions.has(selectedResource.uri) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => subscribeToResource(selectedResource.uri)}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
)}
|
||||
{resourceSubscriptionsSupported &&
|
||||
resourceSubscriptions.has(selectedResource.uri) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
unsubscribeFromResource(selectedResource.uri)
|
||||
}
|
||||
>
|
||||
Unsubscribe
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => readResource(selectedResource.uri)}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : selectedResource ? (
|
||||
<JsonView
|
||||
data={resourceContent}
|
||||
className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
) : selectedTemplate ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
{selectedTemplate.description}
|
||||
</p>
|
||||
{selectedTemplate.uriTemplate
|
||||
.match(/{([^}]+)}/g)
|
||||
?.map((param) => {
|
||||
const key = param.slice(1, -1);
|
||||
return (
|
||||
<div key={key}>
|
||||
<Label htmlFor={key}>{key}</Label>
|
||||
<Combobox
|
||||
id={key}
|
||||
placeholder={`Enter ${key}`}
|
||||
value={templateValues[key] || ""}
|
||||
onChange={(value) =>
|
||||
handleTemplateValueChange(key, value)
|
||||
}
|
||||
onInputChange={(value) =>
|
||||
handleTemplateValueChange(key, value)
|
||||
}
|
||||
options={completions[key] || []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
onClick={handleReadTemplateResource}
|
||||
disabled={Object.keys(templateValues).length === 0}
|
||||
>
|
||||
Read Resource
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Select a resource or template from the list to view its
|
||||
contents
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -35,40 +35,42 @@ const RootsTab = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<TabsContent value="roots" className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Configure the root directories that the server can access
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<TabsContent value="roots">
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Configure the root directories that the server can access
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{roots.map((root, index) => (
|
||||
<div key={index} className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder="file:// URI"
|
||||
value={root.uri}
|
||||
onChange={(e) => updateRoot(index, "uri", e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removeRoot(index)}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
{roots.map((root, index) => (
|
||||
<div key={index} className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder="file:// URI"
|
||||
value={root.uri}
|
||||
onChange={(e) => updateRoot(index, "uri", e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removeRoot(index)}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={addRoot}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Root
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={addRoot}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Root
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
|
||||
@@ -33,33 +33,37 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<TabsContent value="sampling" className="h-96">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
When the server requests LLM sampling, requests will appear here for
|
||||
approval.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="mt-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">Recent Requests</h3>
|
||||
{pendingRequests.map((request) => (
|
||||
<div key={request.id} className="p-4 border rounded-lg space-y-4">
|
||||
<JsonView
|
||||
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded"
|
||||
data={JSON.stringify(request.request)}
|
||||
/>
|
||||
<TabsContent value="sampling">
|
||||
<div className="h-96">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
When the server requests LLM sampling, requests will appear here for
|
||||
approval.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="mt-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">Recent Requests</h3>
|
||||
{pendingRequests.map((request) => (
|
||||
<div key={request.id} className="p-4 border rounded-lg space-y-4">
|
||||
<JsonView
|
||||
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded"
|
||||
data={JSON.stringify(request.request)}
|
||||
/>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button onClick={() => handleApprove(request.id)}>Approve</Button>
|
||||
<Button variant="outline" onClick={() => onReject(request.id)}>
|
||||
Reject
|
||||
</Button>
|
||||
<div className="flex space-x-2">
|
||||
<Button onClick={() => handleApprove(request.id)}>
|
||||
Approve
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onReject(request.id)}>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{pendingRequests.length === 0 && (
|
||||
<p className="text-gray-500">No pending requests</p>
|
||||
)}
|
||||
))}
|
||||
{pendingRequests.length === 0 && (
|
||||
<p className="text-gray-500">No pending requests</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
|
||||
@@ -51,9 +51,12 @@ interface SidebarProps {
|
||||
setEnv: (env: Record<string, string>) => void;
|
||||
bearerToken: string;
|
||||
setBearerToken: (token: string) => void;
|
||||
headerName?: string;
|
||||
setHeaderName?: (name: string) => void;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
stdErrNotifications: StdErrNotification[];
|
||||
clearStdErrNotifications: () => void;
|
||||
logLevel: LoggingLevel;
|
||||
sendLogLevelRequest: (level: LoggingLevel) => void;
|
||||
loggingSupported: boolean;
|
||||
@@ -75,9 +78,12 @@ const Sidebar = ({
|
||||
setEnv,
|
||||
bearerToken,
|
||||
setBearerToken,
|
||||
headerName,
|
||||
setHeaderName,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
stdErrNotifications,
|
||||
clearStdErrNotifications,
|
||||
logLevel,
|
||||
sendLogLevelRequest,
|
||||
loggingSupported,
|
||||
@@ -92,7 +98,7 @@ const Sidebar = ({
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="flex items-center">
|
||||
<h1 className="ml-2 text-lg font-semibold">
|
||||
MCP Inspector v{version}
|
||||
@@ -103,14 +109,19 @@ const Sidebar = ({
|
||||
<div className="p-4 flex-1 overflow-auto">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Transport Type</label>
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="transport-type-select"
|
||||
>
|
||||
Transport Type
|
||||
</label>
|
||||
<Select
|
||||
value={transportType}
|
||||
onValueChange={(value: "stdio" | "sse") =>
|
||||
setTransportType(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id="transport-type-select">
|
||||
<SelectValue placeholder="Select transport type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -123,8 +134,11 @@ const Sidebar = ({
|
||||
{transportType === "stdio" ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Command</label>
|
||||
<label className="text-sm font-medium" htmlFor="command-input">
|
||||
Command
|
||||
</label>
|
||||
<Input
|
||||
id="command-input"
|
||||
placeholder="Command"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
@@ -132,8 +146,14 @@ const Sidebar = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Arguments</label>
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="arguments-input"
|
||||
>
|
||||
Arguments
|
||||
</label>
|
||||
<Input
|
||||
id="arguments-input"
|
||||
placeholder="Arguments (space-separated)"
|
||||
value={args}
|
||||
onChange={(e) => setArgs(e.target.value)}
|
||||
@@ -144,8 +164,11 @@ const Sidebar = ({
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">URL</label>
|
||||
<label className="text-sm font-medium" htmlFor="sse-url-input">
|
||||
URL
|
||||
</label>
|
||||
<Input
|
||||
id="sse-url-input"
|
||||
placeholder="URL"
|
||||
value={sseUrl}
|
||||
onChange={(e) => setSseUrl(e.target.value)}
|
||||
@@ -157,6 +180,8 @@ const Sidebar = ({
|
||||
variant="outline"
|
||||
onClick={() => setShowBearerToken(!showBearerToken)}
|
||||
className="flex items-center w-full"
|
||||
data-testid="auth-button"
|
||||
aria-expanded={showBearerToken}
|
||||
>
|
||||
{showBearerToken ? (
|
||||
<ChevronDown className="w-4 h-4 mr-2" />
|
||||
@@ -167,11 +192,28 @@ const Sidebar = ({
|
||||
</Button>
|
||||
{showBearerToken && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Bearer Token</label>
|
||||
<label className="text-sm font-medium">Header Name</label>
|
||||
<Input
|
||||
placeholder="Authorization"
|
||||
onChange={(e) =>
|
||||
setHeaderName && setHeaderName(e.target.value)
|
||||
}
|
||||
data-testid="header-input"
|
||||
className="font-mono"
|
||||
value={headerName}
|
||||
/>
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="bearer-token-input"
|
||||
>
|
||||
Bearer Token
|
||||
</label>
|
||||
<Input
|
||||
id="bearer-token-input"
|
||||
placeholder="Bearer Token"
|
||||
value={bearerToken}
|
||||
onChange={(e) => setBearerToken(e.target.value)}
|
||||
data-testid="bearer-token-input"
|
||||
className="font-mono"
|
||||
type="password"
|
||||
/>
|
||||
@@ -187,6 +229,7 @@ const Sidebar = ({
|
||||
onClick={() => setShowEnvVars(!showEnvVars)}
|
||||
className="flex items-center w-full"
|
||||
data-testid="env-vars-button"
|
||||
aria-expanded={showEnvVars}
|
||||
>
|
||||
{showEnvVars ? (
|
||||
<ChevronDown className="w-4 h-4 mr-2" />
|
||||
@@ -201,6 +244,7 @@ const Sidebar = ({
|
||||
<div key={idx} className="space-y-2 pb-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label={`Environment variable key ${idx + 1}`}
|
||||
placeholder="Key"
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
@@ -243,6 +287,7 @@ const Sidebar = ({
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label={`Environment variable value ${idx + 1}`}
|
||||
type={shownEnvVars.has(key) ? "text" : "password"}
|
||||
placeholder="Value"
|
||||
value={value}
|
||||
@@ -309,6 +354,7 @@ const Sidebar = ({
|
||||
onClick={() => setShowConfig(!showConfig)}
|
||||
className="flex items-center w-full"
|
||||
data-testid="config-button"
|
||||
aria-expanded={showConfig}
|
||||
>
|
||||
{showConfig ? (
|
||||
<ChevronDown className="w-4 h-4 mr-2" />
|
||||
@@ -325,8 +371,11 @@ const Sidebar = ({
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="text-sm font-medium text-green-600">
|
||||
{configKey}
|
||||
<label
|
||||
className="text-sm font-medium text-green-600 break-all"
|
||||
htmlFor={`${configKey}-input`}
|
||||
>
|
||||
{configItem.label}
|
||||
</label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -339,6 +388,7 @@ const Sidebar = ({
|
||||
</div>
|
||||
{typeof configItem.value === "number" ? (
|
||||
<Input
|
||||
id={`${configKey}-input`}
|
||||
type="number"
|
||||
data-testid={`${configKey}-input`}
|
||||
value={configItem.value}
|
||||
@@ -365,7 +415,7 @@ const Sidebar = ({
|
||||
setConfig(newConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id={`${configKey}-input`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -375,6 +425,7 @@ const Sidebar = ({
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id={`${configKey}-input`}
|
||||
data-testid={`${configKey}-input`}
|
||||
value={configItem.value}
|
||||
onChange={(e) => {
|
||||
@@ -398,7 +449,13 @@ const Sidebar = ({
|
||||
<div className="space-y-2">
|
||||
{connectionStatus === "connected" && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button data-testid="connect-button" onClick={onConnect}>
|
||||
<Button
|
||||
data-testid="connect-button"
|
||||
onClick={() => {
|
||||
onDisconnect();
|
||||
onConnect();
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
{transportType === "stdio" ? "Restart" : "Reconnect"}
|
||||
</Button>
|
||||
@@ -448,19 +505,26 @@ const Sidebar = ({
|
||||
|
||||
{loggingSupported && connectionStatus === "connected" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Logging Level</label>
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="logging-level-select"
|
||||
>
|
||||
Logging Level
|
||||
</label>
|
||||
<Select
|
||||
value={logLevel}
|
||||
onValueChange={(value: LoggingLevel) =>
|
||||
sendLogLevelRequest(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger id="logging-level-select">
|
||||
<SelectValue placeholder="Select logging level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(LoggingLevelSchema.enum).map((level) => (
|
||||
<SelectItem value={level}>{level}</SelectItem>
|
||||
<SelectItem key={level} value={level}>
|
||||
{level}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -470,9 +534,19 @@ const Sidebar = ({
|
||||
{stdErrNotifications.length > 0 && (
|
||||
<>
|
||||
<div className="mt-4 border-t border-gray-200 pt-4">
|
||||
<h3 className="text-sm font-medium">
|
||||
Error output from MCP server
|
||||
</h3>
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-medium">
|
||||
Error output from MCP server
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearStdErrNotifications}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 max-h-80 overflow-y-auto">
|
||||
{stdErrNotifications.map((notification, index) => (
|
||||
<div
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm";
|
||||
import DynamicJsonForm from "./DynamicJsonForm";
|
||||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
ListToolsResult,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Send } from "lucide-react";
|
||||
import { Loader2, Send } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ListPane from "./ListPane";
|
||||
import JsonView from "./JsonView";
|
||||
@@ -31,7 +32,7 @@ const ToolsTab = ({
|
||||
tools: Tool[];
|
||||
listTools: () => void;
|
||||
clearTools: () => void;
|
||||
callTool: (name: string, params: Record<string, unknown>) => void;
|
||||
callTool: (name: string, params: Record<string, unknown>) => Promise<void>;
|
||||
selectedTool: Tool | null;
|
||||
setSelectedTool: (tool: Tool | null) => void;
|
||||
toolResult: CompatibilityCallToolResult | null;
|
||||
@@ -39,8 +40,16 @@ const ToolsTab = ({
|
||||
error: string | null;
|
||||
}) => {
|
||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||
const [isToolRunning, setIsToolRunning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setParams({});
|
||||
const params = Object.entries(
|
||||
selectedTool?.inputSchema.properties ?? [],
|
||||
).map(([key, value]) => [
|
||||
key,
|
||||
generateDefaultValue(value as JsonSchemaType),
|
||||
]);
|
||||
setParams(Object.fromEntries(params));
|
||||
}, [selectedTool]);
|
||||
|
||||
const renderToolResult = () => {
|
||||
@@ -66,11 +75,18 @@ const ToolsTab = ({
|
||||
return (
|
||||
<>
|
||||
<h4 className="font-semibold mb-2">
|
||||
Tool Result: {isError ? "Error" : "Success"}
|
||||
Tool Result:{" "}
|
||||
{isError ? (
|
||||
<span className="text-red-600 font-semibold">Error</span>
|
||||
) : (
|
||||
<span className="text-green-600 font-semibold">Success</span>
|
||||
)}
|
||||
</h4>
|
||||
{structuredResult.content.map((item, index) => (
|
||||
<div key={index} className="mb-2">
|
||||
{item.type === "text" && <JsonView data={item.text} />}
|
||||
{item.type === "text" && (
|
||||
<JsonView data={item.text} isError={isError} />
|
||||
)}
|
||||
{item.type === "image" && (
|
||||
<img
|
||||
src={`data:${item.mimeType};base64,${item.data}`}
|
||||
@@ -106,147 +122,179 @@ const ToolsTab = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<TabsContent value="tools" className="grid grid-cols-2 gap-4">
|
||||
<ListPane
|
||||
items={tools}
|
||||
listItems={listTools}
|
||||
clearItems={() => {
|
||||
clearTools();
|
||||
setSelectedTool(null);
|
||||
}}
|
||||
setSelectedItem={setSelectedTool}
|
||||
renderItem={(tool) => (
|
||||
<>
|
||||
<span className="flex-1">{tool.name}</span>
|
||||
<span className="text-sm text-gray-500 text-right">
|
||||
{tool.description}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
title="Tools"
|
||||
buttonText={nextCursor ? "List More Tools" : "List Tools"}
|
||||
isButtonDisabled={!nextCursor && tools.length > 0}
|
||||
/>
|
||||
<TabsContent value="tools">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ListPane
|
||||
items={tools}
|
||||
listItems={listTools}
|
||||
clearItems={() => {
|
||||
clearTools();
|
||||
setSelectedTool(null);
|
||||
}}
|
||||
setSelectedItem={setSelectedTool}
|
||||
renderItem={(tool) => (
|
||||
<>
|
||||
<span className="flex-1">{tool.name}</span>
|
||||
<span className="text-sm text-gray-500 text-right">
|
||||
{tool.description}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
title="Tools"
|
||||
buttonText={nextCursor ? "List More Tools" : "List Tools"}
|
||||
isButtonDisabled={!nextCursor && tools.length > 0}
|
||||
/>
|
||||
|
||||
<div className="bg-card rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h3 className="font-semibold">
|
||||
{selectedTool ? selectedTool.name : "Select a tool"}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{selectedTool ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
{selectedTool.description}
|
||||
</p>
|
||||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||
([key, value]) => {
|
||||
const prop = value as JsonSchemaType;
|
||||
return (
|
||||
<div key={key}>
|
||||
<Label
|
||||
htmlFor={key}
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
{key}
|
||||
</Label>
|
||||
{prop.type === "boolean" ? (
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Checkbox
|
||||
<div className="bg-card rounded-lg shadow">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="font-semibold">
|
||||
{selectedTool ? selectedTool.name : "Select a tool"}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{selectedTool ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
{selectedTool.description}
|
||||
</p>
|
||||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||
([key, value]) => {
|
||||
const prop = value as JsonSchemaType;
|
||||
return (
|
||||
<div key={key}>
|
||||
<Label
|
||||
htmlFor={key}
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
{key}
|
||||
</Label>
|
||||
{prop.type === "boolean" ? (
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Checkbox
|
||||
id={key}
|
||||
name={key}
|
||||
checked={!!params[key]}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setParams({
|
||||
...params,
|
||||
[key]: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={key}
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{prop.description || "Toggle this option"}
|
||||
</label>
|
||||
</div>
|
||||
) : prop.type === "string" ? (
|
||||
<Textarea
|
||||
id={key}
|
||||
name={key}
|
||||
checked={!!params[key]}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
placeholder={prop.description}
|
||||
value={(params[key] as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
setParams({
|
||||
...params,
|
||||
[key]: checked,
|
||||
[key]: e.target.value,
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
<label
|
||||
htmlFor={key}
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{prop.description || "Toggle this option"}
|
||||
</label>
|
||||
</div>
|
||||
) : prop.type === "string" ? (
|
||||
<Textarea
|
||||
id={key}
|
||||
name={key}
|
||||
placeholder={prop.description}
|
||||
value={(params[key] as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
setParams({
|
||||
...params,
|
||||
[key]: e.target.value,
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
) : prop.type === "object" || prop.type === "array" ? (
|
||||
<div className="mt-1">
|
||||
<DynamicJsonForm
|
||||
schema={{
|
||||
type: prop.type,
|
||||
properties: prop.properties,
|
||||
description: prop.description,
|
||||
items: prop.items,
|
||||
}}
|
||||
value={
|
||||
(params[key] as JsonValue) ??
|
||||
generateDefaultValue(prop)
|
||||
}
|
||||
onChange={(newValue: JsonValue) => {
|
||||
) : prop.type === "object" || prop.type === "array" ? (
|
||||
<div className="mt-1">
|
||||
<DynamicJsonForm
|
||||
schema={{
|
||||
type: prop.type,
|
||||
properties: prop.properties,
|
||||
description: prop.description,
|
||||
items: prop.items,
|
||||
}}
|
||||
value={
|
||||
(params[key] as JsonValue) ??
|
||||
generateDefaultValue(prop)
|
||||
}
|
||||
onChange={(newValue: JsonValue) => {
|
||||
setParams({
|
||||
...params,
|
||||
[key]: newValue,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : prop.type === "number" ||
|
||||
prop.type === "integer" ? (
|
||||
<Input
|
||||
type="number"
|
||||
id={key}
|
||||
name={key}
|
||||
placeholder={prop.description}
|
||||
value={(params[key] as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
setParams({
|
||||
...params,
|
||||
[key]: newValue,
|
||||
});
|
||||
}}
|
||||
[key]: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type={
|
||||
prop.type === "number" || prop.type === "integer"
|
||||
? "number"
|
||||
: "text"
|
||||
}
|
||||
id={key}
|
||||
name={key}
|
||||
placeholder={prop.description}
|
||||
value={(params[key] as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
setParams({
|
||||
...params,
|
||||
[key]:
|
||||
prop.type === "number" ||
|
||||
prop.type === "integer"
|
||||
? Number(e.target.value)
|
||||
: e.target.value,
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
<Button onClick={() => callTool(selectedTool.name, params)}>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Run Tool
|
||||
</Button>
|
||||
{toolResult && renderToolResult()}
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Select a tool from the list to view its details and run it
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
) : (
|
||||
<div className="mt-1">
|
||||
<DynamicJsonForm
|
||||
schema={{
|
||||
type: prop.type,
|
||||
properties: prop.properties,
|
||||
description: prop.description,
|
||||
items: prop.items,
|
||||
}}
|
||||
value={params[key] as JsonValue}
|
||||
onChange={(newValue: JsonValue) => {
|
||||
setParams({
|
||||
...params,
|
||||
[key]: newValue,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
setIsToolRunning(true);
|
||||
await callTool(selectedTool.name, params);
|
||||
} finally {
|
||||
setIsToolRunning(false);
|
||||
}
|
||||
}}
|
||||
disabled={isToolRunning}
|
||||
>
|
||||
{isToolRunning ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Run Tool
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{toolResult && renderToolResult()}
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Select a tool from the list to view its details and run it
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, jest } from "@jest/globals";
|
||||
import DynamicJsonForm from "../DynamicJsonForm";
|
||||
import type { JsonSchemaType } from "../DynamicJsonForm";
|
||||
import type { JsonSchemaType } from "@/utils/jsonUtils";
|
||||
|
||||
describe("DynamicJsonForm String Fields", () => {
|
||||
const renderForm = (props = {}) => {
|
||||
@@ -93,3 +93,47 @@ describe("DynamicJsonForm Integer Fields", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DynamicJsonForm Complex Fields", () => {
|
||||
const renderForm = (props = {}) => {
|
||||
const defaultProps = {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
// The simplified JsonSchemaType does not accept oneOf fields
|
||||
// But they exist in the more-complete JsonSchema7Type
|
||||
nested: { oneOf: [{ type: "string" }, { type: "integer" }] },
|
||||
},
|
||||
} as unknown as JsonSchemaType,
|
||||
value: undefined,
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
return render(<DynamicJsonForm {...defaultProps} {...props} />);
|
||||
};
|
||||
|
||||
describe("Basic Operations", () => {
|
||||
it("should render textbox and autoformat button, but no switch-to-form button", () => {
|
||||
renderForm();
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveProperty("type", "textarea");
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons).toHaveLength(1);
|
||||
expect(buttons[0]).toHaveProperty("textContent", "Format JSON");
|
||||
});
|
||||
|
||||
it("should pass changed values to onChange", () => {
|
||||
const onChange = jest.fn();
|
||||
renderForm({ onChange });
|
||||
|
||||
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" }`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { describe, it, beforeEach, jest } from "@jest/globals";
|
||||
import Sidebar from "../Sidebar";
|
||||
import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants";
|
||||
@@ -29,6 +30,7 @@ describe("Sidebar Environment Variables", () => {
|
||||
onConnect: jest.fn(),
|
||||
onDisconnect: jest.fn(),
|
||||
stdErrNotifications: [],
|
||||
clearStdErrNotifications: jest.fn(),
|
||||
logLevel: "info" as const,
|
||||
sendLogLevelRequest: jest.fn(),
|
||||
loggingSupported: true,
|
||||
@@ -108,6 +110,157 @@ describe("Sidebar Environment Variables", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
const openAuthSection = () => {
|
||||
const button = screen.getByTestId("auth-button");
|
||||
fireEvent.click(button);
|
||||
};
|
||||
|
||||
it("should update bearer token", () => {
|
||||
const setBearerToken = jest.fn();
|
||||
renderSidebar({
|
||||
bearerToken: "",
|
||||
setBearerToken,
|
||||
transportType: "sse", // Set transport type to SSE
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||
|
||||
expect(setBearerToken).toHaveBeenCalledWith("new_token");
|
||||
});
|
||||
|
||||
it("should update header name", () => {
|
||||
const setHeaderName = jest.fn();
|
||||
renderSidebar({
|
||||
headerName: "Authorization",
|
||||
setHeaderName,
|
||||
transportType: "sse",
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
const headerInput = screen.getByTestId("header-input");
|
||||
fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } });
|
||||
|
||||
expect(setHeaderName).toHaveBeenCalledWith("X-Custom-Auth");
|
||||
});
|
||||
|
||||
it("should clear bearer token", () => {
|
||||
const setBearerToken = jest.fn();
|
||||
renderSidebar({
|
||||
bearerToken: "existing_token",
|
||||
setBearerToken,
|
||||
transportType: "sse", // Set transport type to SSE
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||
fireEvent.change(tokenInput, { target: { value: "" } });
|
||||
|
||||
expect(setBearerToken).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("should properly render bearer token input", () => {
|
||||
const { rerender } = renderSidebar({
|
||||
bearerToken: "existing_token",
|
||||
transportType: "sse", // Set transport type to SSE
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
// Token input should be a password field
|
||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||
expect(tokenInput).toHaveProperty("type", "password");
|
||||
|
||||
// Update the token
|
||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||
|
||||
// Rerender with updated token
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<Sidebar
|
||||
{...defaultProps}
|
||||
bearerToken="new_token"
|
||||
transportType="sse"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
// Token input should still exist after update
|
||||
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should maintain token visibility state after update", () => {
|
||||
const { rerender } = renderSidebar({
|
||||
bearerToken: "existing_token",
|
||||
transportType: "sse", // Set transport type to SSE
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
// Token input should be a password field
|
||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||
expect(tokenInput).toHaveProperty("type", "password");
|
||||
|
||||
// Update the token
|
||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||
|
||||
// Rerender with updated token
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<Sidebar
|
||||
{...defaultProps}
|
||||
bearerToken="new_token"
|
||||
transportType="sse"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
// Token input should still exist after update
|
||||
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should maintain header name when toggling auth section", () => {
|
||||
renderSidebar({
|
||||
headerName: "X-API-Key",
|
||||
transportType: "sse",
|
||||
});
|
||||
|
||||
// Open auth section
|
||||
openAuthSection();
|
||||
|
||||
// Verify header name is displayed
|
||||
const headerInput = screen.getByTestId("header-input");
|
||||
expect(headerInput).toHaveValue("X-API-Key");
|
||||
|
||||
// Close auth section
|
||||
const authButton = screen.getByTestId("auth-button");
|
||||
fireEvent.click(authButton);
|
||||
|
||||
// Reopen auth section
|
||||
fireEvent.click(authButton);
|
||||
|
||||
// Verify header name is still preserved
|
||||
expect(screen.getByTestId("header-input")).toHaveValue("X-API-Key");
|
||||
});
|
||||
|
||||
it("should display default header name when not specified", () => {
|
||||
renderSidebar({
|
||||
headerName: undefined,
|
||||
transportType: "sse",
|
||||
});
|
||||
|
||||
openAuthSection();
|
||||
|
||||
const headerInput = screen.getByTestId("header-input");
|
||||
expect(headerInput).toHaveAttribute("placeholder", "Authorization");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Key Editing", () => {
|
||||
it("should maintain order when editing first key", () => {
|
||||
const setEnv = jest.fn();
|
||||
@@ -343,6 +496,7 @@ describe("Sidebar Environment Variables", () => {
|
||||
expect(setConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||
label: "Request Timeout",
|
||||
description: "Timeout for requests to the MCP server (ms)",
|
||||
value: 5000,
|
||||
},
|
||||
@@ -350,6 +504,56 @@ describe("Sidebar Environment Variables", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should update MCP server proxy address", () => {
|
||||
const setConfig = jest.fn();
|
||||
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
||||
|
||||
openConfigSection();
|
||||
|
||||
const proxyAddressInput = screen.getByTestId(
|
||||
"MCP_PROXY_FULL_ADDRESS-input",
|
||||
);
|
||||
fireEvent.change(proxyAddressInput, {
|
||||
target: { value: "http://localhost:8080" },
|
||||
});
|
||||
|
||||
expect(setConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
MCP_PROXY_FULL_ADDRESS: {
|
||||
label: "Inspector Proxy Address",
|
||||
description:
|
||||
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
|
||||
value: "http://localhost:8080",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should update max total timeout", () => {
|
||||
const setConfig = jest.fn();
|
||||
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
||||
|
||||
openConfigSection();
|
||||
|
||||
const maxTotalTimeoutInput = screen.getByTestId(
|
||||
"MCP_REQUEST_MAX_TOTAL_TIMEOUT-input",
|
||||
);
|
||||
fireEvent.change(maxTotalTimeoutInput, {
|
||||
target: { value: "10000" },
|
||||
});
|
||||
|
||||
expect(setConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
|
||||
label: "Maximum Total Timeout",
|
||||
description:
|
||||
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
|
||||
value: 10000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle invalid timeout values entered by user", () => {
|
||||
const setConfig = jest.fn();
|
||||
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
||||
@@ -364,6 +568,7 @@ describe("Sidebar Environment Variables", () => {
|
||||
expect(setConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||
label: "Request Timeout",
|
||||
description: "Timeout for requests to the MCP server (ms)",
|
||||
value: 0,
|
||||
},
|
||||
@@ -409,6 +614,7 @@ describe("Sidebar Environment Variables", () => {
|
||||
expect(setConfig).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||
label: "Request Timeout",
|
||||
description: "Timeout for requests to the MCP server (ms)",
|
||||
value: 3000,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { describe, it, expect, jest } from "@jest/globals";
|
||||
import "@testing-library/jest-dom";
|
||||
import ToolsTab from "../ToolsTab";
|
||||
@@ -43,7 +43,7 @@ describe("ToolsTab", () => {
|
||||
tools: mockTools,
|
||||
listTools: jest.fn(),
|
||||
clearTools: jest.fn(),
|
||||
callTool: jest.fn(),
|
||||
callTool: jest.fn(async () => {}),
|
||||
selectedTool: null,
|
||||
setSelectedTool: jest.fn(),
|
||||
toolResult: null,
|
||||
@@ -59,14 +59,16 @@ describe("ToolsTab", () => {
|
||||
);
|
||||
};
|
||||
|
||||
it("should reset input values when switching tools", () => {
|
||||
it("should reset input values when switching tools", async () => {
|
||||
const { rerender } = renderToolsTab({
|
||||
selectedTool: mockTools[0],
|
||||
});
|
||||
|
||||
// Enter a value in the first tool's input
|
||||
const input = screen.getByRole("spinbutton") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "42" } });
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "42" } });
|
||||
});
|
||||
expect(input.value).toBe("42");
|
||||
|
||||
// Switch to second tool
|
||||
@@ -80,7 +82,8 @@ describe("ToolsTab", () => {
|
||||
const newInput = screen.getByRole("spinbutton") as HTMLInputElement;
|
||||
expect(newInput.value).toBe("");
|
||||
});
|
||||
it("should handle integer type inputs", () => {
|
||||
|
||||
it("should handle integer type inputs", async () => {
|
||||
renderToolsTab({
|
||||
selectedTool: mockTools[1], // Use the tool with integer type
|
||||
});
|
||||
@@ -93,10 +96,49 @@ describe("ToolsTab", () => {
|
||||
expect(input.value).toBe("42");
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /run tool/i });
|
||||
fireEvent.click(submitButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[1].name, {
|
||||
count: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable button and change text while tool is running", async () => {
|
||||
// Create a promise that we can resolve later
|
||||
let resolvePromise: ((value: unknown) => void) | undefined;
|
||||
const mockPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
// Mock callTool to return our promise
|
||||
const mockCallTool = jest.fn().mockReturnValue(mockPromise);
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: mockTools[0],
|
||||
callTool: mockCallTool,
|
||||
});
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /run tool/i });
|
||||
expect(submitButton.getAttribute("disabled")).toBeNull();
|
||||
|
||||
// Click the button and verify immediate state changes
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
// Verify button is disabled and text changed
|
||||
expect(submitButton.getAttribute("disabled")).not.toBeNull();
|
||||
expect(submitButton.textContent).toBe("Running...");
|
||||
|
||||
// Resolve the promise to simulate tool completion
|
||||
await act(async () => {
|
||||
if (resolvePromise) {
|
||||
await resolvePromise({});
|
||||
}
|
||||
});
|
||||
|
||||
expect(submitButton.getAttribute("disabled")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,4 +54,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export { Button };
|
||||
|
||||
@@ -2,8 +2,7 @@ import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
|
||||
@@ -2,8 +2,7 @@ import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
|
||||
Reference in New Issue
Block a user