refactor(ui): move the MCP tool test form off antd

The tool test panel drove its argument fields through an antd Form, so the
call payload was whatever rc-field-form happened to have mounted. It now runs
on react-hook-form with shadcn controls, and the payload itself lives in
toolCallArguments.ts as a pure function of the schema fields plus the entered
values.

Fields bind by index rather than by name, because an MCP tool's JSON schema
can name a property anything: a key containing a dot would be one flat key to
antd but a nested path to react-hook-form. Binding to args.0, args.1 and
zipping back to the real keys at submit time keeps the emitted arguments
identical whatever the server calls its properties.

Coercion, the blank filter, the required and JSON rules, and the params
wrapper for nested-object schemas all keep their previous behaviour, and the
neutral colours in the panel move onto tokens so it reads correctly in dark
mode.
This commit is contained in:
Yuneng Jiang 2026-08-18 15:38:38 -07:00
parent 0b82b087fd
commit fc32eb081a
No known key found for this signature in database
6 changed files with 927 additions and 425 deletions

View file

@ -715,12 +715,6 @@
}
},
"src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}

View file

@ -0,0 +1,330 @@
import React from "react";
import { useForm, type Control } from "react-hook-form";
import { CircleHelp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { FieldGroup } from "@/components/shared/form/field";
import { FormField, type FormFieldControlProps } from "@/components/shared/form/FormField";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { InputSchemaProperty } from "@/components/mcp_tools/types";
import {
ToolArgumentField,
ToolArgumentsFormValues,
buildToolCallArguments,
toolArgumentsResolver,
} from "./toolCallArguments";
const isPlainObject = (value: unknown): value is Record<string, any> =>
typeof value === "object" && value !== null && !Array.isArray(value);
function buildArrayItems(items?: InputSchemaProperty | InputSchemaProperty[]): any[] {
if (!items) {
return [];
}
if (Array.isArray(items)) {
return items.map((item) => buildDefaultValue(item)).filter((value) => value !== undefined);
}
const itemDefault = buildDefaultValue(items);
if (itemDefault === undefined) {
return [];
}
return [itemDefault];
}
function buildDefaultValue(prop?: InputSchemaProperty, overrideDefault?: any): any {
if (!prop) {
return undefined;
}
const effectiveDefault = overrideDefault !== undefined ? overrideDefault : prop.default;
if (prop.type === "object") {
const base = isPlainObject(effectiveDefault) ? { ...effectiveDefault } : {};
if (prop.properties) {
Object.entries(prop.properties).forEach(([childKey, childProp]) => {
base[childKey] = buildDefaultValue(childProp, base[childKey]);
});
}
return base;
}
if (prop.type === "array") {
if (Array.isArray(effectiveDefault)) {
const itemSchema = prop.items;
if (!itemSchema) {
return effectiveDefault;
}
if (effectiveDefault.length === 0) {
const sample = buildArrayItems(itemSchema);
return sample.length ? sample : effectiveDefault;
}
if (Array.isArray(itemSchema)) {
return effectiveDefault.map((value, index) => {
const schema = itemSchema[index] ?? itemSchema[itemSchema.length - 1];
return buildDefaultValue(schema, value);
});
}
return effectiveDefault.map((value) => buildDefaultValue(itemSchema, value));
}
if (effectiveDefault !== undefined) {
return effectiveDefault;
}
return buildArrayItems(prop.items);
}
if (effectiveDefault !== undefined) {
return effectiveDefault;
}
switch (prop.type) {
case "integer":
case "number":
return 0;
case "boolean":
return false;
case "string":
default:
return "";
}
}
const getInitialValueForField = (prop: InputSchemaProperty): any => {
const defaultValue = buildDefaultValue(prop);
if (prop.type === "object" || prop.type === "array") {
const fallback = prop.type === "array" ? [] : {};
return JSON.stringify(defaultValue ?? fallback, null, 2);
}
return defaultValue;
};
const argumentLabel = (field: ToolArgumentField): React.ReactNode => (
<span className="flex items-center">
{field.key}
{field.required && <span className="ml-1 text-destructive">*</span>}
{field.prop.description && (
<Tooltip>
<TooltipTrigger render={<CircleHelp className="ml-2 size-3.5 shrink-0 cursor-help text-muted-foreground" />} />
<TooltipContent>{field.prop.description}</TooltipContent>
</Tooltip>
)}
</span>
);
const BOOLEAN_ITEMS = [
{ value: true, label: "True" },
{ value: false, label: "False" },
];
const booleanTitle = (value: unknown): string | undefined => {
if (value === true) return "True";
if (value === false) return "False";
return undefined;
};
const JsonArgumentControl: React.FC<{
field: ToolArgumentField;
control: FormFieldControlProps<ToolArgumentsFormValues, `args.${number}`>;
}> = ({ field, control }) => {
const isObject = field.prop.type === "object";
const fallbackPlaceholder = isObject ? `Enter JSON object for ${field.key}` : `Enter JSON array for ${field.key}`;
return (
<div className="space-y-2">
<Textarea
{...control}
rows={isObject ? 6 : 4}
value={(control.value as string) ?? ""}
placeholder={field.prop.description || fallbackPlaceholder}
spellCheck={false}
data-testid={`textarea-${field.key}`}
className="rounded-lg font-mono"
/>
<p className="text-xs text-muted-foreground">
{isObject ? "Provide a valid JSON object." : "Provide a valid JSON array."}
</p>
</div>
);
};
const ToolArgumentControl: React.FC<{
field: ToolArgumentField;
control: FormFieldControlProps<ToolArgumentsFormValues, `args.${number}`>;
}> = ({ field, control }) => {
if (field.prop.type === "string" && field.prop.enum) {
return (
<select
{...control}
value={(control.value as string) ?? ""}
className="w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm shadow-xs transition-colors focus:border-ring focus:ring-3 focus:ring-ring/50 focus:outline-hidden"
>
{!field.required && <option value="">Select {field.key}</option>}
{field.prop.enum.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
);
}
if (field.prop.type === "number" || field.prop.type === "integer") {
return (
<Input
{...control}
type="number"
step={field.prop.type === "integer" ? 1 : "any"}
value={(control.value as number | string) ?? ""}
placeholder={field.prop.description || `Enter ${field.key}`}
className="rounded-lg"
/>
);
}
if (field.prop.type === "boolean") {
return (
<Select
items={field.required ? BOOLEAN_ITEMS : [{ value: "", label: `Select ${field.key}` }, ...BOOLEAN_ITEMS]}
value={control.value ?? ""}
onValueChange={control.onChange}
>
<SelectTrigger
id={control.id}
aria-invalid={control["aria-invalid"]}
title={booleanTitle(control.value)}
className="w-full"
>
<SelectValue placeholder={`Select ${field.key}`} />
</SelectTrigger>
<SelectContent>
{!field.required && <SelectItem value="">Select {field.key}</SelectItem>}
<SelectItem value={true}>True</SelectItem>
<SelectItem value={false}>False</SelectItem>
</SelectContent>
</Select>
);
}
if (field.prop.type === "object" || field.prop.type === "array") {
return <JsonArgumentControl field={field} control={control} />;
}
return (
<Input
{...control}
value={(control.value as string) ?? ""}
placeholder={field.prop.description || `Enter ${field.key}`}
className="rounded-lg"
/>
);
};
const ToolArgumentFields: React.FC<{
fields: readonly ToolArgumentField[];
control: Control<ToolArgumentsFormValues>;
singleInputFallback: boolean;
}> = ({ fields, control, singleInputFallback }) => {
if (singleInputFallback) {
return (
<FieldGroup>
<FormField
control={control}
name="args.0"
label={
<span>
Input <span className="text-destructive">*</span>
</span>
}
>
{(field) => (
<Input
{...field}
value={(field.value as string) ?? ""}
placeholder="Enter input for this tool"
className="rounded-lg"
/>
)}
</FormField>
</FieldGroup>
);
}
if (fields.length === 0) {
return (
<div className="rounded-lg border border-border bg-muted py-6 text-center">
<div className="mx-auto max-w-sm">
<h4 className="mb-1 text-sm font-medium text-foreground">No Parameters Required</h4>
<p className="text-xs text-muted-foreground">This tool can be called without any input parameters.</p>
</div>
</div>
);
}
return (
<FieldGroup>
{fields.map((field, index) => (
<FormField
key={`${field.key}-${index}`}
control={control}
name={`args.${index}` as const}
label={argumentLabel(field)}
>
{(itemControl) => <ToolArgumentControl field={field} control={itemControl} />}
</FormField>
))}
</FieldGroup>
);
};
const callButtonLabel = (isLoading: boolean, hasRun: boolean): string => {
if (isLoading) return "Calling Tool...";
return hasRun ? "Call Again" : "Call Tool";
};
export const ToolArgumentsForm: React.FC<{
fields: readonly ToolArgumentField[];
singleInputFallback: boolean;
isLoading: boolean;
hasRun: boolean;
onRun: (args: Record<string, unknown>) => void;
}> = ({ fields, singleInputFallback, isLoading, hasRun, onRun }) => {
const form = useForm<ToolArgumentsFormValues>({
defaultValues: { args: fields.map((field) => getInitialValueForField(field.prop)) },
resolver: toolArgumentsResolver(fields),
});
const submit = form.handleSubmit((values) => onRun(buildToolCallArguments(fields, values.args)));
return (
<TooltipProvider>
<form onSubmit={submit} className="space-y-3">
<ToolArgumentFields fields={fields} control={form.control} singleInputFallback={singleInputFallback} />
<div className="border-t border-border pt-3">
<Button
type="button"
onClick={() => void submit()}
disabled={isLoading}
aria-busy={isLoading}
className="w-full"
>
{isLoading && <UiLoadingSpinner className="size-4" />}
{callButtonLabel(isLoading, hasRun)}
</Button>
</div>
</form>
</TooltipProvider>
);
};

View file

@ -1,5 +1,7 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { UserEvent } from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ToolTestPanel } from "./ToolTestPanel";
@ -157,3 +159,183 @@ describe("ToolTestPanel defaults", () => {
expect(callButton).toHaveAttribute("type", "button");
});
});
describe("ToolTestPanel argument payload", () => {
const submitPanel = async (schema: InputSchema | string, drive?: (user: UserEvent) => Promise<void>) => {
const onSubmit = vi.fn();
render(
<ToolTestPanel
tool={buildTool(schema)}
onSubmit={onSubmit}
isLoading={false}
result={null}
error={null}
onClose={vi.fn()}
/>,
);
const user = userEvent.setup();
if (drive) {
await drive(user);
}
await user.click(screen.getByRole("button", { name: "Call Tool" }));
return onSubmit;
};
it("sends a typed string under its own key, whitespace trimmed", async () => {
const onSubmit = await submitPanel(
{ type: "object", properties: { message: { type: "string" } } },
async (user) => {
await user.type(screen.getByLabelText("message"), " hello world ");
},
);
expect(onSubmit).toHaveBeenCalledWith({ message: "hello world" });
});
it("sends what the user typed into the fallback input when the tool has no real schema", async () => {
const onSubmit = vi.fn();
render(
<ToolTestPanel
tool={buildTool("tool_input_schema")}
onSubmit={onSubmit}
isLoading={false}
result={null}
error={null}
onClose={vi.fn()}
/>,
);
const user = userEvent.setup();
await user.type(screen.getByPlaceholderText("Enter input for this tool"), " do the thing ");
await user.click(screen.getByRole("button", { name: "Call Tool" }));
expect(onSubmit).toHaveBeenCalledWith({ input: "do the thing" });
});
it("coerces integer fields to truncated numbers and number fields to floats", async () => {
const onSubmit = await submitPanel(
{ type: "object", properties: { attempts: { type: "integer" }, ratio: { type: "number" } } },
async (user) => {
await user.clear(screen.getByLabelText("attempts"));
await user.type(screen.getByLabelText("attempts"), "7.9");
await user.clear(screen.getByLabelText("ratio"));
await user.type(screen.getByLabelText("ratio"), "1.25");
},
);
expect(onSubmit).toHaveBeenCalledWith({ attempts: 7, ratio: 1.25 });
});
it("sends a real boolean, not the string 'true', when the boolean select is changed", async () => {
const onSubmit = await submitPanel(
{ type: "object", properties: { active: { type: "boolean", default: false } } },
async (user) => {
await user.click(screen.getByLabelText("active"));
await user.click(await screen.findByText("True"));
},
);
expect(onSubmit).toHaveBeenCalledWith({ active: true });
});
it("parses object and array textareas into real JSON values", async () => {
const onSubmit = await submitPanel(
{
type: "object",
properties: { payload: { type: "object" }, tags: { type: "array" } },
},
async (user) => {
await user.clear(screen.getByTestId("textarea-payload"));
await user.type(screen.getByTestId("textarea-payload"), '{{"a":1}');
await user.clear(screen.getByTestId("textarea-tags"));
await user.type(screen.getByTestId("textarea-tags"), '[["x","y"]');
},
);
expect(onSubmit).toHaveBeenCalledWith({ payload: { a: 1 }, tags: ["x", "y"] });
});
it("omits a field whose value is left empty", async () => {
const onSubmit = await submitPanel({
type: "object",
properties: { message: { type: "string" }, note: { type: "string" } },
});
expect(onSubmit).toHaveBeenCalledWith({});
});
it("keeps a dotted schema key flat rather than nesting it", async () => {
const onSubmit = await submitPanel(
{ type: "object", properties: { "filter.name": { type: "string" } } },
async (user) => {
await user.type(screen.getByLabelText("filter.name"), "acme");
},
);
expect(onSubmit).toHaveBeenCalledWith({ "filter.name": "acme" });
expect(onSubmit.mock.calls[0][0]).not.toHaveProperty("filter");
});
it("sends the option picked from an enum select", async () => {
const onSubmit = await submitPanel(
{ type: "object", properties: { mode: { type: "string", enum: ["fast", "thorough"] } } },
async (user) => {
await user.selectOptions(screen.getByLabelText("mode"), "thorough");
},
);
expect(onSubmit).toHaveBeenCalledWith({ mode: "thorough" });
});
it("wraps the arguments back under params for a nested params schema", async () => {
const onSubmit = await submitPanel(
{
type: "object",
properties: {
params: { type: "object", properties: { query: { type: "string" } } },
},
},
async (user) => {
await user.type(screen.getByLabelText("query"), "widgets");
},
);
expect(onSubmit).toHaveBeenCalledWith({ params: { query: "widgets" } });
});
it("blocks the call and shows the required message when a required field is empty", async () => {
const onSubmit = await submitPanel({
type: "object",
properties: { message: { type: "string" } },
required: ["message"],
});
expect(await screen.findByText("Please enter message")).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it("blocks the call and shows the JSON message when an object field holds invalid JSON", async () => {
const onSubmit = await submitPanel(
{ type: "object", properties: { payload: { type: "object" } } },
async (user) => {
await user.clear(screen.getByTestId("textarea-payload"));
await user.type(screen.getByTestId("textarea-payload"), "not json");
},
);
expect(await screen.findByText("Invalid JSON")).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it("sends the seeded defaults when the user submits without touching anything", async () => {
const onSubmit = await submitPanel({
type: "object",
properties: {
ratio: { type: "number", default: 0.4 },
active: { type: "boolean", default: true },
label: { type: "string", default: "seeded" },
},
});
expect(onSubmit).toHaveBeenCalledWith({ ratio: 0.4, active: true, label: "seeded" });
});
});

View file

@ -1,106 +1,13 @@
import React from "react";
import { CircleHelp, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { MCPTool, InputSchema, InputSchemaProperty } from "@/components/mcp_tools/types";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { MCPTool, InputSchema } from "@/components/mcp_tools/types";
import { resolveLogoSrc } from "@/lib/assetPaths";
import { Form, Select, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { X } from "lucide-react";
import { toast } from "@/lib/toast";
const isPlainObject = (value: unknown): value is Record<string, any> =>
typeof value === "object" && value !== null && !Array.isArray(value);
function buildArrayItems(items?: InputSchemaProperty | InputSchemaProperty[]): any[] {
if (!items) {
return [];
}
if (Array.isArray(items)) {
return items.map((item) => buildDefaultValue(item)).filter((value) => value !== undefined);
}
const itemDefault = buildDefaultValue(items);
if (itemDefault === undefined) {
return [];
}
return [itemDefault];
}
function buildDefaultValue(prop?: InputSchemaProperty, overrideDefault?: any): any {
if (!prop) {
return undefined;
}
const effectiveDefault = overrideDefault !== undefined ? overrideDefault : prop.default;
if (prop.type === "object") {
const base = isPlainObject(effectiveDefault) ? { ...effectiveDefault } : {};
if (prop.properties) {
Object.entries(prop.properties).forEach(([childKey, childProp]) => {
base[childKey] = buildDefaultValue(childProp, base[childKey]);
});
}
return base;
}
if (prop.type === "array") {
if (Array.isArray(effectiveDefault)) {
const itemSchema = prop.items;
if (!itemSchema) {
return effectiveDefault;
}
if (effectiveDefault.length === 0) {
const sample = buildArrayItems(itemSchema);
return sample.length ? sample : effectiveDefault;
}
if (Array.isArray(itemSchema)) {
return effectiveDefault.map((value, index) => {
const schema = itemSchema[index] ?? itemSchema[itemSchema.length - 1];
return buildDefaultValue(schema, value);
});
}
return effectiveDefault.map((value) => buildDefaultValue(itemSchema, value));
}
if (effectiveDefault !== undefined) {
return effectiveDefault;
}
return buildArrayItems(prop.items);
}
if (effectiveDefault !== undefined) {
return effectiveDefault;
}
switch (prop.type) {
case "integer":
case "number":
return 0;
case "boolean":
return false;
case "string":
default:
return "";
}
}
const getInitialValueForField = (prop: InputSchemaProperty): any => {
const defaultValue = buildDefaultValue(prop);
if (prop.type === "object" || prop.type === "array") {
const fallback = prop.type === "array" ? [] : {};
return JSON.stringify(defaultValue ?? fallback, null, 2);
}
return defaultValue;
};
import { ToolArgumentsForm } from "./ToolArgumentsForm";
import { ToolArgumentField, hasNestedParamsSchema, toolArgumentFields } from "./toolCallArguments";
export function ToolTestPanel({
tool,
@ -117,7 +24,6 @@ export function ToolTestPanel({
error: Error | null;
onClose: () => void;
}) {
const [form] = Form.useForm();
const [viewMode, setViewMode] = React.useState<"formatted" | "json">("formatted");
const [startTime, setStartTime] = React.useState<number | null>(null);
const [duration, setDuration] = React.useState<number | null>(null);
@ -158,87 +64,16 @@ export function ToolTestPanel({
return schema;
}, [schema]);
React.useEffect(() => {
form.resetFields();
const argumentFields: readonly ToolArgumentField[] = React.useMemo(
() => toolArgumentFields(actualSchema),
[actualSchema],
);
const wrapInParams = React.useMemo(() => hasNestedParamsSchema(schema), [schema]);
if (!actualSchema.properties) {
return;
}
const initialValues: Record<string, any> = {};
Object.entries(actualSchema.properties).forEach(([key, prop]) => {
initialValues[key] = getInitialValueForField(prop);
});
form.setFieldsValue(initialValues);
}, [form, actualSchema, tool]);
const handleSubmit = (values: Record<string, any>) => {
const start = Date.now();
setStartTime(start);
const runToolCall = (args: Record<string, unknown>) => {
setStartTime(Date.now());
setDuration(null);
// Convert form values to proper types based on schema
const convertedValues: Record<string, any> = {};
const schemaToUse = actualSchema;
Object.entries(values).forEach(([key, value]) => {
const prop = schemaToUse.properties?.[key];
// Strip leading/trailing whitespace from string inputs before submitting
const normalizedValue = typeof value === "string" ? value.trim() : value;
if (prop && normalizedValue !== null && normalizedValue !== undefined && normalizedValue !== "") {
switch (prop.type) {
case "boolean":
convertedValues[key] = normalizedValue === "true" || normalizedValue === true;
break;
case "number":
case "integer": {
const numericValue = Number(normalizedValue);
convertedValues[key] = Number.isNaN(numericValue)
? normalizedValue
: prop.type === "integer"
? Math.trunc(numericValue)
: numericValue;
break;
}
case "object":
case "array": {
try {
const parsed = typeof normalizedValue === "string" ? JSON.parse(normalizedValue) : normalizedValue;
const isValidObject =
prop.type === "object" && parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
const isValidArray = prop.type === "array" && Array.isArray(parsed);
if ((prop.type === "object" && isValidObject) || (prop.type === "array" && isValidArray)) {
convertedValues[key] = parsed;
} else {
convertedValues[key] = normalizedValue;
}
} catch (err) {
convertedValues[key] = normalizedValue;
}
break;
}
case "string":
convertedValues[key] = String(normalizedValue);
break;
default:
convertedValues[key] = normalizedValue;
}
} else if (normalizedValue !== null && normalizedValue !== undefined && normalizedValue !== "") {
convertedValues[key] = normalizedValue;
}
});
// If this was a nested params structure, wrap the values back in params
const submitValues =
schema.properties &&
schema.properties.params &&
schema.properties.params.type === "object" &&
schema.properties.params.properties
? { params: convertedValues }
: convertedValues;
onSubmit(submitValues);
onSubmit(wrapInParams ? { params: args } : args);
};
// Track when result changes to calculate duration
@ -300,7 +135,7 @@ export function ToolTestPanel({
return (
<div className="space-y-4 h-full">
{/* Compact Header */}
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
<div className="flex items-center justify-between pb-3 border-b border-border">
<div className="flex items-center space-x-3">
{tool.mcp_info.logo_url && (
// eslint-disable-next-line @next/next/no-img-element
@ -312,7 +147,7 @@ export function ToolTestPanel({
)}
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
<h2 className="text-lg font-semibold text-gray-900">Test Tool:</h2>
<h2 className="text-lg font-semibold text-foreground">Test Tool:</h2>
<div
className="group inline-flex items-center space-x-1 bg-slate-50 hover:bg-slate-100 px-3 py-1 rounded-md cursor-pointer transition-colors border border-slate-200"
onClick={handleCopyToolName}
@ -334,8 +169,8 @@ export function ToolTestPanel({
</svg>
</div>
</div>
<p className="text-xs text-gray-600">{tool.description}</p>
<p className="text-xs text-gray-500">Provider: {tool.mcp_info.server_name}</p>
<p className="text-xs text-muted-foreground">{tool.description}</p>
<p className="text-xs text-muted-foreground">Provider: {tool.mcp_info.server_name}</p>
</div>
</div>
<Button
@ -343,7 +178,7 @@ export function ToolTestPanel({
variant="ghost"
size="icon-sm"
aria-label="Close"
className="text-gray-500 hover:text-gray-700"
className="text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</Button>
@ -352,211 +187,47 @@ export function ToolTestPanel({
{/* Two Column Layout - Always Side by Side */}
<div className="grid grid-cols-2 gap-4 h-full">
{/* Left Column - Input Parameters */}
<div className="bg-white border border-gray-200 rounded-lg">
<div className="border-b border-gray-100 px-4 py-2">
<div className="bg-card border border-border rounded-lg">
<div className="border-b border-border px-4 py-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900">Input Parameters</h3>
<Tooltip title="Configure the input parameters for this tool call">
<InfoCircleOutlined className="text-gray-400 hover:text-gray-600" />
</Tooltip>
<h3 className="text-sm font-semibold text-foreground">Input Parameters</h3>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={<CircleHelp className="size-4 cursor-help text-muted-foreground hover:text-foreground" />}
/>
<TooltipContent>Configure the input parameters for this tool call</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="p-4">
<Form form={form} onFinish={handleSubmit} layout="vertical" className="space-y-3">
{typeof tool.inputSchema === "string" ? (
<div className="space-y-3">
<Form.Item
label={
<span className="text-sm font-medium text-gray-700">
Input <span className="text-red-500">*</span>
</span>
}
name="input"
rules={[{ required: true, message: "Please enter input for this tool" }]}
className="mb-3"
>
<Input
placeholder="Enter input for this tool"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
</div>
) : actualSchema.properties === undefined ? (
<div className="text-center py-6 bg-gray-50 rounded-lg border border-gray-200">
<div className="max-w-sm mx-auto">
<h4 className="text-sm font-medium text-gray-900 mb-1">No Parameters Required</h4>
<p className="text-xs text-gray-500">This tool can be called without any input parameters.</p>
</div>
</div>
) : (
<div className="space-y-3">
{Object.entries(actualSchema.properties).map(([key, prop]) => {
const initialValue = getInitialValueForField(prop);
const fieldKey = `${tool.name}-${key}`;
return (
<Form.Item
key={fieldKey}
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
{key} {actualSchema.required?.includes(key) && <span className="text-red-500">*</span>}
{prop.description && (
<Tooltip title={prop.description}>
<InfoCircleOutlined className="ml-2 text-gray-400 hover:text-gray-600" />
</Tooltip>
)}
</span>
}
name={key}
initialValue={initialValue}
rules={[
{
required: actualSchema.required?.includes(key),
message: `Please enter ${key}`,
},
...(prop.type === "object" || prop.type === "array"
? [
{
validator: (_rule: any, value: any) => {
if (
(value === undefined || value === null || value === "") &&
!actualSchema.required?.includes(key)
) {
return Promise.resolve();
}
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
const isValidObject =
prop.type === "object" &&
parsed !== null &&
typeof parsed === "object" &&
!Array.isArray(parsed);
const isValidArray = prop.type === "array" && Array.isArray(parsed);
if (
(prop.type === "object" && isValidObject) ||
(prop.type === "array" && isValidArray)
) {
return Promise.resolve();
}
return Promise.reject(
new Error(
prop.type === "object"
? "Please enter a JSON object"
: "Please enter a JSON array",
),
);
} catch (error) {
return Promise.reject(new Error("Invalid JSON"));
}
},
},
]
: []),
]}
className="mb-3"
>
{prop.type === "string" && prop.enum && (
<select
className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-xs focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm transition-colors"
defaultValue={(initialValue as string) ?? ""}
>
{!actualSchema.required?.includes(key) && <option value="">Select {key}</option>}
{prop.enum.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
)}
{prop.type === "string" && !prop.enum && (
<Input
placeholder={prop.description || `Enter ${key}`}
defaultValue={(initialValue as string) ?? ""}
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
)}
{(prop.type === "number" || prop.type === "integer") && (
<input
type="number"
step={prop.type === "integer" ? 1 : "any"}
placeholder={prop.description || `Enter ${key}`}
defaultValue={initialValue ?? 0}
className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-xs focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm transition-colors"
/>
)}
{prop.type === "boolean" && (
<Select
placeholder={`Select ${key}`}
allowClear={!actualSchema.required?.includes(key)}
className="w-full"
>
<Select.Option value={true}>True</Select.Option>
<Select.Option value={false}>False</Select.Option>
</Select>
)}
{(prop.type === "object" || prop.type === "array") && (
<div className="space-y-2">
<textarea
rows={prop.type === "object" ? 6 : 4}
placeholder={
prop.description ||
(prop.type === "object"
? `Enter JSON object for ${key}`
: `Enter JSON array for ${key}`)
}
defaultValue={(initialValue as string) ?? (prop.type === "object" ? "{}" : "[]")}
spellCheck={false}
data-testid={`textarea-${key}`}
className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-xs focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm font-mono"
/>
<p className="text-xs text-gray-500">
{prop.type === "object" ? "Provide a valid JSON object." : "Provide a valid JSON array."}
</p>
</div>
)}
</Form.Item>
);
})}
</div>
)}
<div className="pt-3 border-t border-gray-100">
<Button
type="button"
onClick={() => form.submit()}
disabled={isLoading}
aria-busy={isLoading}
className="w-full"
>
{isLoading && <UiLoadingSpinner className="size-4" />}
{isLoading ? "Calling Tool..." : result || error ? "Call Again" : "Call Tool"}
</Button>
</div>
</Form>
<ToolArgumentsForm
key={tool.name}
fields={argumentFields}
singleInputFallback={typeof tool.inputSchema === "string"}
isLoading={isLoading}
hasRun={Boolean(result || error)}
onRun={runToolCall}
/>
</div>
</div>
{/* Right Column - Tool Result */}
<div className="bg-white border border-gray-200 rounded-lg">
<div className="border-b border-gray-100 px-4 py-2">
<h3 className="text-sm font-semibold text-gray-900">Tool Result</h3>
<div className="bg-card border border-border rounded-lg">
<div className="border-b border-border px-4 py-2">
<h3 className="text-sm font-semibold text-foreground">Tool Result</h3>
</div>
<div className="p-4">
{!result && !error && !isLoading ? (
/* Empty State */
<div className="flex flex-col justify-center items-center h-48 text-gray-500">
<div className="flex flex-col justify-center items-center h-48 text-muted-foreground">
<div className="text-center max-w-sm">
<div className="mb-3">
<svg
className="mx-auto h-12 w-12 text-gray-300"
className="mx-auto h-12 w-12 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@ -569,8 +240,8 @@ export function ToolTestPanel({
/>
</svg>
</div>
<h4 className="text-sm font-medium text-gray-900 mb-1">Ready to Call Tool</h4>
<p className="text-xs text-gray-500 leading-relaxed">
<h4 className="text-sm font-medium text-foreground mb-1">Ready to Call Tool</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
Configure the input parameters and click &quot;Call Tool&quot; to see the results here.
</p>
</div>
@ -579,10 +250,15 @@ export function ToolTestPanel({
<div className="space-y-3">
{/* Result Control Bar */}
{result && !isLoading && !error && (
<div className="p-2 bg-green-50 border border-green-200 rounded-lg">
<div className="p-2 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<svg className="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg
className="h-4 w-4 text-green-500 dark:text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
@ -590,20 +266,24 @@ export function ToolTestPanel({
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h4 className="text-xs font-medium text-green-900">Tool executed successfully</h4>
<h4 className="text-xs font-medium text-green-900 dark:text-green-100">
Tool executed successfully
</h4>
{duration !== null && (
<span className="text-xs text-green-600 ml-1"> {(duration / 1000).toFixed(2)}s</span>
<span className="text-xs text-green-600 dark:text-green-400 ml-1">
{(duration / 1000).toFixed(2)}s
</span>
)}
</div>
<div className="flex items-center space-x-1">
<div className="flex bg-white rounded-sm border border-green-300 p-0.5">
<div className="flex bg-card rounded-sm border border-green-300 dark:border-green-700 p-0.5">
<button
onClick={() => setViewMode("formatted")}
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
viewMode === "formatted"
? "bg-green-100 text-green-800"
: "text-green-600 hover:text-green-800"
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"
: "text-green-600 dark:text-green-400 hover:text-green-800 dark:text-green-200"
}`}
>
Formatted
@ -612,8 +292,8 @@ export function ToolTestPanel({
onClick={() => setViewMode("json")}
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
viewMode === "json"
? "bg-green-100 text-green-800"
: "text-green-600 hover:text-green-800"
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"
: "text-green-600 dark:text-green-400 hover:text-green-800 dark:text-green-200"
}`}
>
JSON
@ -622,7 +302,7 @@ export function ToolTestPanel({
<button
onClick={handleCopyResult}
className="p-1 hover:bg-green-100 rounded-sm text-green-700"
className="p-1 hover:bg-green-100 dark:bg-green-900 rounded-sm text-green-700 dark:text-green-300"
title="Copy response"
>
<svg
@ -647,21 +327,26 @@ export function ToolTestPanel({
<div className="max-h-96 overflow-y-auto">
{isLoading && (
<div className="flex flex-col justify-center items-center h-48 text-gray-500">
<div className="flex flex-col justify-center items-center h-48 text-muted-foreground">
<div className="relative">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-gray-200"></div>
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-600 border-t-transparent absolute top-0"></div>
<div className="animate-spin rounded-full h-8 w-8 border-2 border-border"></div>
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-600 dark:border-blue-400 border-t-transparent absolute top-0"></div>
</div>
<p className="text-sm font-medium mt-3">Calling tool...</p>
<p className="text-xs text-gray-400 mt-1">Please wait while we process your request</p>
<p className="text-xs text-muted-foreground mt-1">Please wait while we process your request</p>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<div className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg p-3">
<div className="flex items-start space-x-2">
<div className="shrink-0">
<svg className="h-4 w-4 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg
className="h-4 w-4 text-red-400 dark:text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
@ -672,13 +357,15 @@ export function ToolTestPanel({
</div>
<div className="flex-1">
<div className="flex items-center space-x-2 mb-1">
<h4 className="text-xs font-medium text-red-900">Tool Call Failed</h4>
<h4 className="text-xs font-medium text-red-900 dark:text-red-100">Tool Call Failed</h4>
{duration !== null && (
<span className="text-xs text-red-600"> {(duration / 1000).toFixed(2)}s</span>
<span className="text-xs text-red-600 dark:text-red-400">
{(duration / 1000).toFixed(2)}s
</span>
)}
</div>
<div className="bg-white border border-red-200 rounded-sm p-2 max-h-48 overflow-y-auto">
<pre className="text-xs whitespace-pre-wrap text-red-700 font-mono">
<div className="bg-card border border-red-200 dark:border-red-800 rounded-sm p-2 max-h-48 overflow-y-auto">
<pre className="text-xs whitespace-pre-wrap text-red-700 dark:text-red-300 font-mono">
{(() => {
return error.message;
})()}
@ -694,16 +381,16 @@ export function ToolTestPanel({
{viewMode === "formatted" ? (
// Formatted View
result.map((content: any, idx: number) => (
<div key={idx} className="border border-gray-200 rounded-lg overflow-hidden">
<div key={idx} className="border border-border rounded-lg overflow-hidden">
{content.type === "text" && (
<div>
<div className="bg-gray-50 px-3 py-1 border-b border-gray-200">
<span className="text-xs font-medium text-gray-700 uppercase tracking-wide">
<div className="bg-muted px-3 py-1 border-b border-border">
<span className="text-xs font-medium text-foreground uppercase tracking-wide">
Text Response
</span>
</div>
<div className="p-3">
<div className="bg-white rounded-sm border border-gray-200 max-h-64 overflow-y-auto">
<div className="bg-card rounded-sm border border-border max-h-64 overflow-y-auto">
<div className="p-3 space-y-2">
{content.text
.split("\n\n")
@ -714,8 +401,8 @@ export function ToolTestPanel({
if (section.startsWith("##")) {
const headerText = section.replace(/^#+\s/, "");
return (
<div key={sectionIndex} className="border-b border-gray-200 pb-1 mb-2">
<h3 className="text-sm font-semibold text-gray-900">{headerText}</h3>
<div key={sectionIndex} className="border-b border-border pb-1 mb-2">
<h3 className="text-sm font-semibold text-foreground">{headerText}</h3>
</div>
);
}
@ -727,9 +414,9 @@ export function ToolTestPanel({
return (
<div
key={sectionIndex}
className="bg-blue-50 border border-blue-200 rounded-sm p-2"
className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-sm p-2"
>
<div className="text-xs text-gray-700 leading-relaxed whitespace-pre-wrap">
<div className="text-xs text-foreground leading-relaxed whitespace-pre-wrap">
{parts.map((part, partIndex) => {
if (urlRegex.test(part)) {
return (
@ -738,7 +425,7 @@ export function ToolTestPanel({
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline break-all"
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:text-blue-200 underline break-all"
>
{part}
</a>
@ -756,9 +443,9 @@ export function ToolTestPanel({
return (
<div
key={sectionIndex}
className="bg-green-50 border-l-4 border-green-400 p-2 rounded-r"
className="bg-green-50 dark:bg-green-950 border-l-4 border-green-400 dark:border-green-600 p-2 rounded-r"
>
<p className="text-xs text-green-800 font-medium whitespace-pre-wrap">
<p className="text-xs text-green-800 dark:text-green-200 font-medium whitespace-pre-wrap">
{section}
</p>
</div>
@ -769,9 +456,9 @@ export function ToolTestPanel({
return (
<div
key={sectionIndex}
className="bg-gray-50 rounded-sm p-2 border border-gray-200"
className="bg-muted rounded-sm p-2 border border-border"
>
<div className="text-xs text-gray-700 leading-relaxed whitespace-pre-wrap font-mono">
<div className="text-xs text-foreground leading-relaxed whitespace-pre-wrap font-mono">
{section}
</div>
</div>
@ -786,13 +473,13 @@ export function ToolTestPanel({
{content.type === "image" && content.url && (
<div>
<div className="bg-gray-50 px-3 py-1 border-b border-gray-200">
<span className="text-xs font-medium text-gray-700 uppercase tracking-wide">
<div className="bg-muted px-3 py-1 border-b border-border">
<span className="text-xs font-medium text-foreground uppercase tracking-wide">
Image Response
</span>
</div>
<div className="p-3">
<div className="bg-gray-50 rounded-sm p-3 border border-gray-200">
<div className="bg-muted rounded-sm p-3 border border-border">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={content.url}
@ -806,16 +493,16 @@ export function ToolTestPanel({
{content.type === "embedded_resource" && (
<div>
<div className="bg-gray-50 px-3 py-1 border-b border-gray-200">
<span className="text-xs font-medium text-gray-700 uppercase tracking-wide">
<div className="bg-muted px-3 py-1 border-b border-border">
<span className="text-xs font-medium text-foreground uppercase tracking-wide">
Embedded Resource
</span>
</div>
<div className="p-3">
<div className="flex items-center space-x-2 p-3 bg-blue-50 border border-blue-200 rounded-sm">
<div className="flex items-center space-x-2 p-3 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-sm">
<div className="shrink-0">
<svg
className="h-5 w-5 text-blue-500"
className="h-5 w-5 text-blue-500 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@ -829,7 +516,7 @@ export function ToolTestPanel({
</svg>
</div>
<div className="flex-1">
<p className="text-xs font-medium text-blue-900">
<p className="text-xs font-medium text-blue-900 dark:text-blue-100">
Resource Type: {content.resource_type}
</p>
{content.url && (
@ -837,7 +524,7 @@ export function ToolTestPanel({
href={content.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-xs text-blue-600 hover:text-blue-800 hover:underline mt-1 transition-colors"
className="inline-flex items-center text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:text-blue-200 hover:underline mt-1 transition-colors"
>
View Resource
<svg className="ml-1 h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
@ -855,9 +542,9 @@ export function ToolTestPanel({
))
) : (
// JSON View
<div className="bg-white rounded-sm border border-gray-200">
<div className="p-3 overflow-auto max-h-80 bg-gray-50">
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-gray-800">
<div className="bg-card rounded-sm border border-border">
<div className="p-3 overflow-auto max-h-80 bg-muted">
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-foreground">
{JSON.stringify(result, null, 2)}
</pre>
</div>

View file

@ -0,0 +1,188 @@
import { describe, expect, it } from "vitest";
import { InputSchema } from "@/components/mcp_tools/types";
import {
ToolArgumentField,
buildToolCallArguments,
hasNestedParamsSchema,
toolArgumentFields,
toolArgumentsResolver,
validateToolArgument,
} from "./toolCallArguments";
const field = (
key: string,
type: string,
required = false,
extra: Record<string, unknown> = {},
): ToolArgumentField => ({
key,
prop: { type, ...extra },
required,
});
describe("toolArgumentFields", () => {
it("preserves schema property order and marks required entries", () => {
const schema: InputSchema = {
type: "object",
properties: { b: { type: "string" }, a: { type: "integer" }, c: { type: "boolean" } },
required: ["a"],
};
expect(toolArgumentFields(schema).map((f) => [f.key, f.required])).toEqual([
["b", false],
["a", true],
["c", false],
]);
});
it("returns no fields when the schema declares no properties", () => {
expect(toolArgumentFields({ type: "object" } as InputSchema)).toEqual([]);
});
});
describe("buildToolCallArguments", () => {
it("keys the payload by schema key, not by field index", () => {
const fields = [field("alpha", "string"), field("beta", "string")];
expect(buildToolCallArguments(fields, ["one", "two"])).toEqual({ alpha: "one", beta: "two" });
});
it("keeps a dotted schema key flat instead of nesting it", () => {
const result = buildToolCallArguments([field("filter.name", "string")], ["acme"]);
expect(result).toEqual({ "filter.name": "acme" });
expect(result).not.toHaveProperty("filter");
});
it("keeps a bracketed schema key flat instead of building an array", () => {
const result = buildToolCallArguments([field("items[0]", "string")], ["x"]);
expect(result).toEqual({ "items[0]": "x" });
expect(result).not.toHaveProperty("items");
});
it("trims strings and drops fields that are blank after trimming", () => {
const fields = [field("kept", "string"), field("blank", "string"), field("spaces", "string")];
expect(buildToolCallArguments(fields, [" hi ", "", " "])).toEqual({ kept: "hi" });
});
it("drops undefined and null values", () => {
const fields = [field("a", "string"), field("b", "string")];
expect(buildToolCallArguments(fields, [undefined, null])).toEqual({});
});
it("truncates integers and preserves floats", () => {
const fields = [field("i", "integer"), field("n", "number")];
expect(buildToolCallArguments(fields, ["7.9", "1.25"])).toEqual({ i: 7, n: 1.25 });
});
it("keeps a non-numeric string as-is on a numeric field", () => {
expect(buildToolCallArguments([field("n", "number")], ["abc"])).toEqual({ n: "abc" });
});
it("emits real booleans from both the string and the boolean form", () => {
const fields = [field("a", "boolean"), field("b", "boolean"), field("c", "boolean")];
expect(buildToolCallArguments(fields, ["true", true, false])).toEqual({ a: true, b: true, c: false });
});
it("parses valid JSON objects and arrays", () => {
const fields = [field("o", "object"), field("a", "array")];
expect(buildToolCallArguments(fields, ['{"k":1}', "[1,2]"])).toEqual({ o: { k: 1 }, a: [1, 2] });
});
it("passes the raw string through when JSON is malformed or the wrong shape", () => {
const fields = [field("o", "object"), field("a", "array")];
expect(buildToolCallArguments(fields, ["not json", '{"k":1}'])).toEqual({ o: "not json", a: '{"k":1}' });
});
it("stringifies a numeric value declared as a string field", () => {
expect(buildToolCallArguments([field("s", "string")], [42])).toEqual({ s: "42" });
});
});
describe("validateToolArgument", () => {
it("reports the required message for a blank required field", () => {
expect(validateToolArgument(field("name", "string", true), " ")).toBe("Please enter name");
});
it("accepts a populated required field", () => {
expect(validateToolArgument(field("name", "string", true), "x")).toBeUndefined();
});
it("ignores an empty optional JSON field", () => {
expect(validateToolArgument(field("o", "object"), "")).toBeUndefined();
});
it("reports malformed JSON", () => {
expect(validateToolArgument(field("o", "object"), "{oops")).toBe("Invalid JSON");
});
it("reports an array supplied to an object field", () => {
expect(validateToolArgument(field("o", "object"), "[1,2]")).toBe("Please enter a JSON object");
});
it("reports an object supplied to an array field", () => {
expect(validateToolArgument(field("a", "array"), '{"k":1}')).toBe("Please enter a JSON array");
});
it("accepts well-formed values for both JSON field types", () => {
expect(validateToolArgument(field("o", "object"), '{"k":1}')).toBeUndefined();
expect(validateToolArgument(field("a", "array"), "[1]")).toBeUndefined();
});
});
describe("toolArgumentsResolver", () => {
it("returns the values untouched when every field is valid", () => {
const fields = [field("a", "string", true), field("o", "object")];
const values = { args: ["x", '{"k":1}'] };
expect(toolArgumentsResolver(fields)(values, undefined, { fields: {}, shouldUseNativeValidation: false })).toEqual({
values,
errors: {},
});
});
it("reports each failing field under its own index", async () => {
const fields = [field("a", "string", true), field("o", "object"), field("b", "string")];
const result = await toolArgumentsResolver(fields)({ args: ["", "{oops", "fine"] }, undefined, {
fields: {},
shouldUseNativeValidation: false,
});
expect(result.values).toEqual({});
expect(result.errors).toEqual({
args: {
0: { type: "validate", message: "Please enter a" },
1: { type: "validate", message: "Invalid JSON" },
},
});
});
});
describe("hasNestedParamsSchema", () => {
it("detects the nested params wrapper", () => {
const schema: InputSchema = {
type: "object",
properties: { params: { type: "object", properties: { q: { type: "string" } } } },
};
expect(hasNestedParamsSchema(schema)).toBe(true);
});
it("rejects a params property that is not an object with properties", () => {
expect(hasNestedParamsSchema({ type: "object", properties: { params: { type: "string" } } })).toBe(false);
expect(hasNestedParamsSchema({ type: "object", properties: { params: { type: "object" } } })).toBe(false);
});
it("rejects a schema with no params property", () => {
expect(hasNestedParamsSchema({ type: "object", properties: { q: { type: "string" } } })).toBe(false);
});
});

View file

@ -0,0 +1,121 @@
import type { Resolver, ResolverResult } from "react-hook-form";
import { InputSchema, InputSchemaProperty } from "@/components/mcp_tools/types";
export interface ToolArgumentField {
readonly key: string;
readonly prop: InputSchemaProperty;
readonly required: boolean;
}
export interface ToolArgumentsFormValues {
args: unknown[];
}
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isJsonField = (prop: InputSchemaProperty): boolean => prop.type === "object" || prop.type === "array";
export const toolArgumentFields = (schema: InputSchema): readonly ToolArgumentField[] =>
Object.entries(schema.properties ?? {}).map(([key, prop]) => ({
key,
prop,
required: schema.required?.includes(key) ?? false,
}));
type ParsedJson = { readonly kind: "ok"; readonly value: unknown } | { readonly kind: "invalid" };
const parseJson = (raw: unknown): ParsedJson => {
if (typeof raw !== "string") return { kind: "ok", value: raw };
try {
return { kind: "ok", value: JSON.parse(raw) };
} catch {
return { kind: "invalid" };
}
};
const isBlank = (value: unknown): boolean => value === undefined || value === null || value === "";
export const validateToolArgument = (field: ToolArgumentField, value: unknown): string | undefined => {
const normalized = typeof value === "string" ? value.trim() : value;
if (field.required && isBlank(normalized)) {
return `Please enter ${field.key}`;
}
if (!isJsonField(field.prop) || (isBlank(value) && !field.required)) {
return undefined;
}
const parsed = parseJson(value);
if (parsed.kind === "invalid") {
return "Invalid JSON";
}
if (field.prop.type === "object" && !isPlainObject(parsed.value)) {
return "Please enter a JSON object";
}
if (field.prop.type === "array" && !Array.isArray(parsed.value)) {
return "Please enter a JSON array";
}
return undefined;
};
const coerceArgument = (prop: InputSchemaProperty, value: unknown): unknown => {
const normalized = typeof value === "string" ? value.trim() : value;
switch (prop.type) {
case "boolean":
return normalized === "true" || normalized === true;
case "number":
case "integer": {
const numeric = Number(normalized);
if (Number.isNaN(numeric)) return normalized;
return prop.type === "integer" ? Math.trunc(numeric) : numeric;
}
case "object":
case "array": {
const parsed = parseJson(normalized);
if (parsed.kind === "invalid") return normalized;
if (prop.type === "object" && isPlainObject(parsed.value)) return parsed.value;
if (prop.type === "array" && Array.isArray(parsed.value)) return parsed.value;
return normalized;
}
case "string":
return String(normalized);
default:
return normalized;
}
};
export const buildToolCallArguments = (
fields: readonly ToolArgumentField[],
values: readonly unknown[],
): Record<string, unknown> =>
Object.fromEntries(
fields
.map((field, index) => ({ field, value: values[index] }))
.filter(({ value }) => !isBlank(typeof value === "string" ? value.trim() : value))
.map(({ field, value }) => [field.key, coerceArgument(field.prop, value)]),
);
export const toolArgumentsResolver =
(fields: readonly ToolArgumentField[]): Resolver<ToolArgumentsFormValues> =>
(values): ResolverResult<ToolArgumentsFormValues> => {
const issues = fields
.map((field, index) => ({ index, message: validateToolArgument(field, values.args[index]) }))
.filter((issue): issue is { index: number; message: string } => issue.message !== undefined);
if (issues.length === 0) {
return { values, errors: {} };
}
return {
values: {},
errors: {
args: Object.fromEntries(issues.map(({ index, message }) => [index, { type: "validate", message }])),
},
};
};
export const hasNestedParamsSchema = (schema: InputSchema): boolean => {
const params = schema.properties?.params;
if (params === undefined) return false;
return params.type === "object" && params.properties !== undefined;
};