* Fix resource refresh button not triggering re-fetch (#1120) The readResource function had an early return when the resource URI was already cached, preventing the Refresh button from re-fetching. Add a force parameter to bypass the cache on refresh, and update the cache-hit path to still sync the displayed content when switching between previously viewed resources. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: don't cache failed resource reads and rename force to bypassCache Failed resource reads were cached in resourceContentMap, causing stale errors to be served on re-click instead of retrying. Errors now only set the display content without polluting the cache. Also renamed the `force` boolean parameter to a `{ bypassCache }` options object for self-documenting call sites. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: remove flaky resource-refresh e2e test The test depends on npx downloading @modelcontextprotocol/server-everything at runtime and uses waitForTimeout, making it unreliable in CI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: invalidate stale cache on refresh failure and rename opts to options Clear cached resource content when a bypassCache refresh fails, so subsequent clicks retry the fetch instead of serving stale data. Rename the inline wrapper parameter from opts to options for consistency. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: surface read errors for resource_link in Tool results Previously, failed resource_link reads left resourceContentMap[uri] empty, so ResourceLinkView rendered an empty expansion with no error feedback when a read failed in the Tools tab. Track errors in a separate resourceErrorMap and thread it through ToolsTab/ToolResults to ResourceLinkView so the user sees the error message. The existing cache-skip behavior is preserved (errors don't pollute the content map, so retries still work). Also adds a ResourcesTab test asserting the Refresh button passes { bypassCache: true } to readResource, to prevent regression of #1120. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: preserve cached content on refresh failure and stop writing error JSON to resourceContent Don't delete resourceContentMap[uri] when a bypassCache refresh fails; a transient network error should not wipe the last-known-good content. To prevent serving stale content after a known error, the cache-hit guard now also skips cache when resourceErrorMap[uri] is set, forcing a re-fetch until a successful read clears the error. Also stop writing JSON.stringify({ error }) into resourceContent in the catch block. sendMCPRequest already sets errors.resources (which the ResourcesTab Alert displays), and the Tools tab shows errors via resourceErrorMap. Writing error JSON into resourceContent was both redundant and could bleed through as JsonView content after callers cleared errors.resources before the next read. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use presence checks instead of truthiness for resource error state Switch from truthiness checks to presence checks in the resource error/content handling so an empty-string error message is still treated as an error. readResource's cache-hit guard now uses `uri in resourceContentMap` / `!(uri in resourceErrorMap)`, ToolResults passes `resourceError?.[item.uri]` (possibly undefined) through to ResourceLinkView, and ResourceLinkView renders the error branch when `resourceError !== undefined`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use hasOwnProperty instead of `in` for resource cache checks The `in` operator also matches inherited properties like `toString` and `constructor`, which would cause false cache hits if a server ever returns a URI with one of those names. Switch the cache-hit guard and the error-map cleanup to Object.prototype.hasOwnProperty.call so only real cached entries match. Object.hasOwn would be cleaner but requires ES2022 lib support which the current tsconfig doesn't include. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: clear resource error on read start, not only on success Previously, resourceErrorMap[uri] was only cleared when a read succeeded. During an in-flight retry (e.g., after clicking Refresh on a previously-failed resource), the stale error would remain in the map and render in ResourceLinkView while the retry was still pending. Clear the error entry after the cache-hit check passes, right before sending the request, so the UI shows the last-known-good content (or a loading state) during the retry instead of the prior error. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use || instead of ?? for error message fallback The ?? operator only falls back on null/undefined, so an Error with an empty message (new Error("")) would produce an empty string and render a blank error block. Switch to || so empty strings also fall through to String(error), which produces a non-empty representation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: only sync resourceContent on cache hit from Resources tab The cache-hit path called setResourceContent unconditionally, which meant expanding a resource_link in the Tools tab could overwrite the Resources tab's displayed content without updating selectedResource. Guard the setResourceContent call with a currentTabRef check so only cache hits from the Resources tab update the display. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
908 lines
36 KiB
TypeScript
908 lines
36 KiB
TypeScript
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { TabsContent } from "@/components/ui/tabs";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm";
|
|
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
|
import {
|
|
generateDefaultValue,
|
|
isPropertyRequired,
|
|
normalizeUnionType,
|
|
resolveRef,
|
|
} from "@/utils/schemaUtils";
|
|
import {
|
|
CompatibilityCallToolResult,
|
|
ListToolsResult,
|
|
Tool,
|
|
ToolAnnotations,
|
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
import {
|
|
Loader2,
|
|
Send,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
ChevronRight,
|
|
AlertCircle,
|
|
Copy,
|
|
CheckCheck,
|
|
} from "lucide-react";
|
|
import { useEffect, useState, useRef } from "react";
|
|
import ListPane from "./ListPane";
|
|
import JsonView from "./JsonView";
|
|
import ToolResults from "./ToolResults";
|
|
import { useToast } from "@/lib/hooks/useToast";
|
|
import useCopy from "@/lib/hooks/useCopy";
|
|
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";
|
|
|
|
/**
|
|
* Extended Tool type that includes optional fields used by the inspector.
|
|
*/
|
|
export interface ExtendedTool extends Tool, WithIcons {
|
|
_meta?: Record<string, unknown>;
|
|
execution?: {
|
|
taskSupport?: "forbidden" | "required" | "optional";
|
|
};
|
|
}
|
|
|
|
// Type guard to safely detect the optional _meta field without using `any`
|
|
const hasMeta = (
|
|
tool: Tool,
|
|
): tool is ExtendedTool & { _meta: Record<string, unknown> } =>
|
|
typeof (tool as ExtendedTool)._meta !== "undefined";
|
|
|
|
// Returns the execution.taskSupport value for a tool, defaulting to "forbidden" per MCP spec
|
|
const getTaskSupport = (
|
|
tool: Tool | null,
|
|
): "forbidden" | "required" | "optional" => {
|
|
if (!tool) return "forbidden";
|
|
const extendedTool = tool as ExtendedTool;
|
|
const taskSupport = extendedTool.execution?.taskSupport;
|
|
if (
|
|
taskSupport === "forbidden" ||
|
|
taskSupport === "required" ||
|
|
taskSupport === "optional"
|
|
) {
|
|
return taskSupport;
|
|
}
|
|
return "forbidden";
|
|
};
|
|
|
|
// Type guard to safely detect the optional annotations field
|
|
const hasAnnotations = (
|
|
tool: Tool,
|
|
): tool is Tool & { annotations: ToolAnnotations } =>
|
|
typeof (tool as { annotations?: unknown }).annotations !== "undefined" &&
|
|
(tool as { annotations?: unknown }).annotations !== null;
|
|
|
|
// Helper to render annotation badges
|
|
// Shows all 4 annotation values with their state (true/false/implied default)
|
|
const AnnotationBadges = ({
|
|
annotations,
|
|
}: {
|
|
annotations: ToolAnnotations | undefined;
|
|
}) => {
|
|
// Spec defaults: readOnlyHint=false, destructiveHint=true, idempotentHint=false, openWorldHint=true
|
|
const getValueAndImplied = (
|
|
value: boolean | undefined,
|
|
defaultValue: boolean,
|
|
): { value: boolean; implied: boolean } => ({
|
|
value: value ?? defaultValue,
|
|
implied: value === undefined,
|
|
});
|
|
|
|
const readOnly = getValueAndImplied(annotations?.readOnlyHint, false);
|
|
const destructive = getValueAndImplied(annotations?.destructiveHint, true);
|
|
const idempotent = getValueAndImplied(annotations?.idempotentHint, false);
|
|
const openWorld = getValueAndImplied(annotations?.openWorldHint, true);
|
|
|
|
// Descriptions from MCP spec
|
|
const badges = [
|
|
{
|
|
label: "Read-only",
|
|
value: readOnly.value,
|
|
implied: readOnly.implied,
|
|
description: "Tool does not modify its environment",
|
|
},
|
|
{
|
|
label: "Destructive",
|
|
value: destructive.value,
|
|
implied: destructive.implied,
|
|
description:
|
|
"Tool may perform destructive updates (delete/overwrite data)",
|
|
},
|
|
{
|
|
label: "Idempotent",
|
|
value: idempotent.value,
|
|
implied: idempotent.implied,
|
|
description: "Calling repeatedly with same args has no additional effect",
|
|
},
|
|
{
|
|
label: "Open-world",
|
|
value: openWorld.value,
|
|
implied: openWorld.implied,
|
|
description:
|
|
"Tool may interact with external entities beyond its local environment",
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{badges.map(({ label, value, implied, description }) => (
|
|
<span
|
|
key={label}
|
|
title={`${description}\n\nValue: ${value ? "Yes" : "No"} (${implied ? "implied default" : "explicitly set"})`}
|
|
className={cn(
|
|
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border",
|
|
"bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-600",
|
|
implied && "border-dashed opacity-60",
|
|
!implied && "border-solid",
|
|
)}
|
|
>
|
|
{value ? "✓" : "✗"} {label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ToolsTab = ({
|
|
tools,
|
|
listTools,
|
|
clearTools,
|
|
callTool,
|
|
selectedTool,
|
|
setSelectedTool,
|
|
toolResult,
|
|
isPollingTask,
|
|
nextCursor,
|
|
error,
|
|
resourceContent,
|
|
resourceError,
|
|
onReadResource,
|
|
serverSupportsTaskRequests,
|
|
}: {
|
|
tools: Tool[];
|
|
listTools: () => void;
|
|
clearTools: () => void;
|
|
callTool: (
|
|
name: string,
|
|
params: Record<string, unknown>,
|
|
metadata?: Record<string, unknown>,
|
|
runAsTask?: boolean,
|
|
) => Promise<CompatibilityCallToolResult>;
|
|
selectedTool: Tool | null;
|
|
setSelectedTool: (tool: Tool | null) => void;
|
|
toolResult: CompatibilityCallToolResult | null;
|
|
isPollingTask?: boolean;
|
|
nextCursor: ListToolsResult["nextCursor"];
|
|
error: string | null;
|
|
resourceContent: Record<string, string>;
|
|
resourceError?: Record<string, string>;
|
|
onReadResource?: (uri: string) => void;
|
|
serverSupportsTaskRequests: boolean;
|
|
}) => {
|
|
const [params, setParams] = useState<Record<string, unknown>>({});
|
|
const [runAsTask, setRunAsTask] = useState(false);
|
|
const [isToolRunning, setIsToolRunning] = useState(false);
|
|
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = 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 = (validateChildren: boolean = false) => {
|
|
const errors = Object.values(formRefs.current).some(
|
|
(ref) =>
|
|
ref &&
|
|
(validateChildren ? !ref.validateJson().isValid : ref.hasJsonError()),
|
|
);
|
|
setHasValidationErrors(errors);
|
|
return errors;
|
|
};
|
|
|
|
useEffect(() => {
|
|
const params = Object.entries(
|
|
selectedTool?.inputSchema.properties ?? [],
|
|
).map(([key, value]) => {
|
|
// First resolve any $ref references
|
|
const resolvedValue = resolveRef(
|
|
value as JsonSchemaType,
|
|
selectedTool?.inputSchema as JsonSchemaType,
|
|
);
|
|
return [
|
|
key,
|
|
generateDefaultValue(
|
|
resolvedValue,
|
|
key,
|
|
selectedTool?.inputSchema as JsonSchemaType,
|
|
),
|
|
];
|
|
});
|
|
setParams(Object.fromEntries(params));
|
|
const toolTaskSupport = serverSupportsTaskRequests
|
|
? getTaskSupport(selectedTool)
|
|
: "forbidden";
|
|
setRunAsTask(toolTaskSupport === "required");
|
|
|
|
// Reset validation errors when switching tools
|
|
setHasValidationErrors(false);
|
|
|
|
// Clear form refs for the previous tool
|
|
formRefs.current = {};
|
|
}, [selectedTool, serverSupportsTaskRequests]);
|
|
|
|
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);
|
|
});
|
|
|
|
const taskSupport = serverSupportsTaskRequests
|
|
? getTaskSupport(selectedTool)
|
|
: "forbidden";
|
|
|
|
return (
|
|
<TabsContent value="tools">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<ListPane
|
|
items={tools}
|
|
listItems={listTools}
|
|
clearItems={() => {
|
|
clearTools();
|
|
setSelectedTool(null);
|
|
setRunAsTask(false);
|
|
}}
|
|
setSelectedItem={setSelectedTool}
|
|
renderItem={(tool) => (
|
|
<div className="flex items-start w-full gap-2">
|
|
<div className="flex-shrink-0 mt-1">
|
|
<IconDisplay icons={(tool as ExtendedTool).icons} size="sm" />
|
|
</div>
|
|
<div className="flex flex-col flex-1 min-w-0">
|
|
<span className="truncate">{tool.title || 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"
|
|
buttonText={nextCursor ? "List More Tools" : "List Tools"}
|
|
isButtonDisabled={!nextCursor && tools.length > 0}
|
|
/>
|
|
|
|
<div className="bg-card border border-border rounded-lg shadow">
|
|
<div className="p-4 border-b border-gray-200 dark:border-border">
|
|
<div className="flex items-center gap-2">
|
|
{selectedTool && (
|
|
<IconDisplay
|
|
icons={(selectedTool as ExtendedTool).icons}
|
|
size="md"
|
|
/>
|
|
)}
|
|
<h3 className="font-semibold">
|
|
{selectedTool
|
|
? selectedTool.title || selectedTool.name
|
|
: "Select a tool"}
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
<div className="p-4">
|
|
{selectedTool ? (
|
|
<div className="space-y-4">
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertTitle>Error</AlertTitle>
|
|
<AlertDescription className="break-all">
|
|
{error}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap max-h-48 overflow-y-auto">
|
|
{selectedTool.description}
|
|
</p>
|
|
<AnnotationBadges
|
|
annotations={
|
|
hasAnnotations(selectedTool)
|
|
? selectedTool.annotations
|
|
: undefined
|
|
}
|
|
/>
|
|
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
|
([key, value]) => {
|
|
// First resolve any $ref references
|
|
const resolvedValue = resolveRef(
|
|
value as JsonSchemaType,
|
|
selectedTool.inputSchema as JsonSchemaType,
|
|
);
|
|
const prop = normalizeUnionType(resolvedValue);
|
|
const inputSchema =
|
|
selectedTool.inputSchema as JsonSchemaType;
|
|
const required = isPropertyRequired(key, inputSchema);
|
|
return (
|
|
<div key={key}>
|
|
<div className="flex justify-between">
|
|
<Label
|
|
htmlFor={key}
|
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
>
|
|
{key}
|
|
{required && (
|
|
<span className="text-red-500 ml-1">*</span>
|
|
)}
|
|
</Label>
|
|
{prop.nullable ? (
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id={key}
|
|
name={key}
|
|
checked={params[key] === null}
|
|
onCheckedChange={(checked: boolean) =>
|
|
setParams({
|
|
...params,
|
|
[key]: checked
|
|
? null
|
|
: prop.type === "array"
|
|
? undefined
|
|
: prop.default !== null
|
|
? prop.default
|
|
: prop.type === "boolean"
|
|
? false
|
|
: prop.type === "string"
|
|
? ""
|
|
: prop.type === "number" ||
|
|
prop.type === "integer"
|
|
? undefined
|
|
: undefined,
|
|
})
|
|
}
|
|
/>
|
|
<label
|
|
htmlFor={key}
|
|
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
>
|
|
null
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div
|
|
role="toolinputwrapper"
|
|
className={`${prop.nullable && params[key] === null ? "pointer-events-none opacity-50" : ""}`}
|
|
>
|
|
{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" && prop.enum ? (
|
|
<Select
|
|
value={
|
|
params[key] === undefined
|
|
? ""
|
|
: String(params[key])
|
|
}
|
|
onValueChange={(value) => {
|
|
if (value === "") {
|
|
setParams({
|
|
...params,
|
|
[key]: undefined,
|
|
});
|
|
} else {
|
|
setParams({
|
|
...params,
|
|
[key]: value,
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger id={key} className="mt-1">
|
|
<SelectValue
|
|
placeholder={
|
|
prop.description || "Select an option"
|
|
}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{prop.enum.map((option) => (
|
|
<SelectItem key={option} value={option}>
|
|
{option}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : prop.type === "string" ? (
|
|
<Textarea
|
|
id={key}
|
|
name={key}
|
|
placeholder={prop.description}
|
|
value={
|
|
params[key] === undefined
|
|
? ""
|
|
: String(params[key])
|
|
}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
if (value === "") {
|
|
// Field cleared - set to undefined
|
|
setParams({
|
|
...params,
|
|
[key]: undefined,
|
|
});
|
|
} else {
|
|
// Field has value - keep as string
|
|
setParams({
|
|
...params,
|
|
[key]: value,
|
|
});
|
|
}
|
|
}}
|
|
className="mt-1"
|
|
/>
|
|
) : prop.type === "object" ||
|
|
prop.type === "array" ? (
|
|
<div className="mt-1">
|
|
<DynamicJsonForm
|
|
ref={(ref) => (formRefs.current[key] = ref)}
|
|
schema={{
|
|
type: prop.type,
|
|
properties: prop.properties,
|
|
description: prop.description,
|
|
items: prop.items,
|
|
}}
|
|
value={
|
|
(params[key] as JsonValue) ??
|
|
generateDefaultValue(prop)
|
|
}
|
|
onChange={(newValue: JsonValue) => {
|
|
setParams({
|
|
...params,
|
|
[key]: newValue,
|
|
});
|
|
// Check validation after a short delay to allow form to update
|
|
setTimeout(checkValidationErrors, 100);
|
|
}}
|
|
/>
|
|
</div>
|
|
) : prop.type === "number" ||
|
|
prop.type === "integer" ? (
|
|
<Input
|
|
type="number"
|
|
id={key}
|
|
name={key}
|
|
placeholder={prop.description}
|
|
value={
|
|
params[key] === undefined
|
|
? ""
|
|
: String(params[key])
|
|
}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
if (value === "") {
|
|
// Field cleared - set to undefined
|
|
setParams({
|
|
...params,
|
|
[key]: undefined,
|
|
});
|
|
} else {
|
|
// Field has value - try to convert to number, but store input either way
|
|
const num = Number(value);
|
|
if (!isNaN(num)) {
|
|
setParams({
|
|
...params,
|
|
[key]: num,
|
|
});
|
|
} else {
|
|
// Store invalid input as string - let server validate
|
|
setParams({
|
|
...params,
|
|
[key]: value,
|
|
});
|
|
}
|
|
}
|
|
}}
|
|
className="mt-1"
|
|
/>
|
|
) : (
|
|
<div className="mt-1">
|
|
<DynamicJsonForm
|
|
ref={(ref) => (formRefs.current[key] = ref)}
|
|
schema={{
|
|
type: prop.type,
|
|
properties: prop.properties,
|
|
description: prop.description,
|
|
items: prop.items,
|
|
}}
|
|
value={params[key] as JsonValue}
|
|
onChange={(newValue: JsonValue) => {
|
|
setParams({
|
|
...params,
|
|
[key]: newValue,
|
|
});
|
|
// Check validation after a short delay to allow form to update
|
|
setTimeout(checkValidationErrors, 100);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
)}
|
|
<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">
|
|
<h4 className="text-sm font-semibold">Output Schema:</h4>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() =>
|
|
setIsOutputSchemaExpanded(!isOutputSchemaExpanded)
|
|
}
|
|
className="h-6 px-2"
|
|
>
|
|
{isOutputSchemaExpanded ? (
|
|
<>
|
|
<ChevronUp className="h-3 w-3 mr-1" />
|
|
Collapse
|
|
</>
|
|
) : (
|
|
<>
|
|
<ChevronDown className="h-3 w-3 mr-1" />
|
|
Expand
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
<div
|
|
className={`transition-all ${
|
|
isOutputSchemaExpanded
|
|
? ""
|
|
: "max-h-[8rem] overflow-y-auto"
|
|
}`}
|
|
>
|
|
<JsonView data={selectedTool.outputSchema} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{selectedTool &&
|
|
hasMeta(selectedTool) &&
|
|
selectedTool._meta && (
|
|
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h4 className="text-sm font-semibold">Meta:</h4>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() =>
|
|
setIsMetadataExpanded(!isMetadataExpanded)
|
|
}
|
|
className="h-6 px-2"
|
|
>
|
|
{isMetadataExpanded ? (
|
|
<>
|
|
<ChevronUp className="h-3 w-3 mr-1" />
|
|
Collapse
|
|
</>
|
|
) : (
|
|
<>
|
|
<ChevronDown className="h-3 w-3 mr-1" />
|
|
Expand
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
<div
|
|
className={`transition-all ${
|
|
isMetadataExpanded
|
|
? ""
|
|
: "max-h-[8rem] overflow-y-auto"
|
|
}`}
|
|
>
|
|
<JsonView data={selectedTool._meta} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{taskSupport !== "forbidden" && (
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="run-as-task"
|
|
checked={runAsTask}
|
|
onCheckedChange={(checked: boolean) =>
|
|
setRunAsTask(checked)
|
|
}
|
|
disabled={taskSupport === "required"}
|
|
/>
|
|
<Label
|
|
htmlFor="run-as-task"
|
|
className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer"
|
|
>
|
|
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 {
|
|
navigator.clipboard.writeText(
|
|
JSON.stringify(params, null, 2),
|
|
);
|
|
setCopied(true);
|
|
} catch (error) {
|
|
toast({
|
|
title: "Error",
|
|
description: `There was an error copying input to the clipboard: ${error instanceof Error ? error.message : String(error)}`,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
{copied ? (
|
|
<CheckCheck className="h-4 w-4 mr-2 dark:text-green-700 text-green-600" />
|
|
) : (
|
|
<Copy className="h-4 w-4 mr-2" />
|
|
)}
|
|
Copy Input
|
|
</Button>
|
|
</div>
|
|
<ToolResults
|
|
toolResult={toolResult}
|
|
selectedTool={selectedTool}
|
|
resourceContent={resourceContent}
|
|
resourceError={resourceError}
|
|
onReadResource={onReadResource}
|
|
isPollingTask={isPollingTask}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<Alert>
|
|
<AlertDescription>
|
|
Select a tool from the list to view its details and run it
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
);
|
|
};
|
|
|
|
export default ToolsTab;
|