Merge pull request #480 from olaservo/fix-required

Fix fundamental schema issue with required fields defined incorrectly on JsonSchemaType
This commit is contained in:
Cliff Hall 2025-06-27 14:09:10 -04:00 • committed by GitHub
commit 6a8c831308
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 520 additions and 81 deletions

View file

@ -16,10 +16,39 @@ interface DynamicJsonFormProps {
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),
);
if (schema.type === "object") {
return Object.values(schema.properties ?? {}).every((prop) =>
supportedTypes.includes(prop.type),
);
}
if (schema.type === "array") {
return !!schema.items && isSimpleObject(schema.items);
}
return false;
};
const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => {
if ("default" in schema && schema.default !== undefined) {
return schema.default;
}
switch (schema.type) {
case "string":
return "";
case "number":
case "integer":
return 0;
case "boolean":
return false;
case "array":
return [];
case "object":
return {};
case "null":
return null;
default:
return null;
}
};
const DynamicJsonForm = ({
@ -113,6 +142,8 @@ const DynamicJsonForm = ({
currentValue: JsonValue,
path: string[] = [],
depth: number = 0,
parentSchema?: JsonSchemaType,
propertyName?: string,
) => {
if (
depth >= maxDepth &&
@ -122,7 +153,8 @@ const DynamicJsonForm = ({
return (
<JsonEditor
value={JSON.stringify(
currentValue ?? generateDefaultValue(propSchema),
currentValue ??
generateDefaultValue(propSchema, propertyName, parentSchema),
null,
2,
)}
@ -140,6 +172,10 @@ const DynamicJsonForm = ({
);
}
// Check if this property is required in the parent schema
const isRequired =
parentSchema?.required?.includes(propertyName || "") ?? false;
switch (propSchema.type) {
case "string":
return (
@ -148,16 +184,11 @@ const DynamicJsonForm = ({
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required fields by setting undefined
// This preserves the distinction between empty string and unset
if (!val && !propSchema.required) {
handleFieldChange(path, undefined);
} else {
handleFieldChange(path, val);
}
// Always allow setting string values, including empty strings
handleFieldChange(path, val);
}}
placeholder={propSchema.description}
required={propSchema.required}
required={isRequired}
/>
);
case "number":
@ -167,9 +198,7 @@ const DynamicJsonForm = ({
value={(currentValue as number)?.toString() ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required number fields
// This preserves the distinction between 0 and unset
if (!val && !propSchema.required) {
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
@ -179,7 +208,7 @@ const DynamicJsonForm = ({
}
}}
placeholder={propSchema.description}
required={propSchema.required}
required={isRequired}
/>
);
case "integer":
@ -190,9 +219,7 @@ const DynamicJsonForm = ({
value={(currentValue as number)?.toString() ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required integer fields
// This preserves the distinction between 0 and unset
if (!val && !propSchema.required) {
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
@ -203,7 +230,7 @@ const DynamicJsonForm = ({
}
}}
placeholder={propSchema.description}
required={propSchema.required}
required={isRequired}
/>
);
case "boolean":
@ -213,9 +240,135 @@ const DynamicJsonForm = ({
checked={(currentValue as boolean) ?? false}
onChange={(e) => handleFieldChange(path, e.target.checked)}
className="w-4 h-4"
required={propSchema.required}
required={isRequired}
/>
);
case "object":
if (!propSchema.properties) {
return (
<JsonEditor
value={JSON.stringify(currentValue ?? {}, null, 2)}
onChange={(newValue) => {
try {
const parsed = JSON.parse(newValue);
handleFieldChange(path, parsed);
setJsonError(undefined);
} catch (err) {
setJsonError(
err instanceof Error ? err.message : "Invalid JSON",
);
}
}}
error={jsonError}
/>
);
}
return (
<div className="space-y-2 border rounded p-3">
{Object.entries(propSchema.properties).map(([key, subSchema]) => (
<div key={key}>
<label className="block text-sm font-medium mb-1">
{key}
{propSchema.required?.includes(key) && (
<span className="text-red-500 ml-1">*</span>
)}
</label>
{renderFormFields(
subSchema as JsonSchemaType,
(currentValue as Record<string, JsonValue>)?.[key],
[...path, key],
depth + 1,
propSchema,
key,
)}
</div>
))}
</div>
);
case "array": {
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
if (!propSchema.items) return null;
// If the array items are simple, render as form fields, otherwise use JSON editor
if (isSimpleObject(propSchema.items)) {
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 = getArrayItemDefault(
propSchema.items as JsonSchemaType,
);
handleFieldChange(path, [...arrayValue, defaultValue]);
}}
title={
propSchema.items?.description
? `Add new ${propSchema.items.description}`
: "Add new item"
}
>
Add Item
</Button>
</div>
</div>
);
}
// For complex arrays, fall back to JSON editor
return (
<JsonEditor
value={JSON.stringify(currentValue ?? [], null, 2)}
onChange={(newValue) => {
try {
const parsed = JSON.parse(newValue);
handleFieldChange(path, parsed);
setJsonError(undefined);
} catch (err) {
setJsonError(
err instanceof Error ? err.message : "Invalid JSON",
);
}
}}
error={jsonError}
/>
);
}
default:
return null;
}

View file

@ -7,7 +7,7 @@ import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import DynamicJsonForm from "./DynamicJsonForm";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import { generateDefaultValue, isPropertyRequired } from "@/utils/schemaUtils";
import {
CompatibilityCallToolResult,
ListToolsResult,
@ -48,7 +48,11 @@ const ToolsTab = ({
selectedTool?.inputSchema.properties ?? [],
).map(([key, value]) => [
key,
generateDefaultValue(value as JsonSchemaType),
generateDefaultValue(
value as JsonSchemaType,
key,
selectedTool?.inputSchema as JsonSchemaType,
),
]);
setParams(Object.fromEntries(params));
}, [selectedTool]);
@ -92,6 +96,9 @@ const ToolsTab = ({
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
([key, value]) => {
const prop = value as JsonSchemaType;
const inputSchema =
selectedTool.inputSchema as JsonSchemaType;
const required = isPropertyRequired(key, inputSchema);
return (
<div key={key}>
<Label
@ -99,6 +106,9 @@ const ToolsTab = ({
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.type === "boolean" ? (
<div className="flex items-center space-x-2 mt-2">

View file

@ -0,0 +1,256 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils";
describe("DynamicJsonForm Array Fields", () => {
const renderSimpleArrayForm = (props = {}) => {
const defaultProps = {
schema: {
type: "array" as const,
description: "Test array field",
items: {
type: "string" as const,
description: "Array item",
},
} satisfies JsonSchemaType,
value: [],
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
const renderComplexArrayForm = (props = {}) => {
const defaultProps = {
schema: {
type: "array" as const,
description: "Test complex array field",
items: {
type: "object" as const,
properties: {
nested: { type: "object" as const },
},
},
} satisfies JsonSchemaType,
value: [],
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Simple Array Rendering", () => {
it("should render form fields for simple array items", () => {
renderSimpleArrayForm({ value: ["item1", "item2"] });
// Should show array description
expect(screen.getByText("Test array field")).toBeDefined();
expect(screen.getByText("Items: Array item")).toBeDefined();
// Should show input fields for each item
const inputs = screen.getAllByRole("textbox");
expect(inputs).toHaveLength(2);
expect(inputs[0]).toHaveProperty("value", "item1");
expect(inputs[1]).toHaveProperty("value", "item2");
// Should show remove buttons
const removeButtons = screen.getAllByText("Remove");
expect(removeButtons).toHaveLength(2);
// Should show add button
expect(screen.getByText("Add Item")).toBeDefined();
});
it("should add new items when Add Item button is clicked", () => {
const onChange = jest.fn();
renderSimpleArrayForm({ value: ["item1"], onChange });
const addButton = screen.getByText("Add Item");
fireEvent.click(addButton);
expect(onChange).toHaveBeenCalledWith(["item1", ""]);
});
it("should remove items when Remove button is clicked", () => {
const onChange = jest.fn();
renderSimpleArrayForm({ value: ["item1", "item2"], onChange });
const removeButtons = screen.getAllByText("Remove");
fireEvent.click(removeButtons[0]);
expect(onChange).toHaveBeenCalledWith(["item2"]);
});
it("should update item values when input changes", () => {
const onChange = jest.fn();
renderSimpleArrayForm({ value: ["item1"], onChange });
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "updated item" } });
expect(onChange).toHaveBeenCalledWith(["updated item"]);
});
it("should handle empty arrays", () => {
renderSimpleArrayForm({ value: [] });
// Should show description and add button but no items
expect(screen.getByText("Test array field")).toBeDefined();
expect(screen.getByText("Add Item")).toBeDefined();
expect(screen.queryByText("Remove")).toBeNull();
});
});
describe("Complex Array Fallback", () => {
it("should render JSON editor for complex arrays", () => {
renderComplexArrayForm();
// Should render as JSON editor (textarea)
const textarea = screen.getByRole("textbox");
expect(textarea).toHaveProperty("type", "textarea");
// Should not show form-specific array controls
expect(screen.queryByText("Add Item")).toBeNull();
expect(screen.queryByText("Remove")).toBeNull();
});
});
describe("Array Type Detection", () => {
it("should detect string arrays as simple", () => {
const schema = {
type: "array" as const,
items: { type: "string" as const },
};
renderSimpleArrayForm({ schema, value: ["test"] });
// Should render form fields, not JSON editor
expect(screen.getByRole("textbox")).not.toHaveProperty(
"type",
"textarea",
);
});
it("should detect number arrays as simple", () => {
const schema = {
type: "array" as const,
items: { type: "number" as const },
};
renderSimpleArrayForm({ schema, value: [1, 2] });
// Should render form fields (number inputs)
const inputs = screen.getAllByRole("spinbutton");
expect(inputs).toHaveLength(2);
});
it("should detect boolean arrays as simple", () => {
const schema = {
type: "array" as const,
items: { type: "boolean" as const },
};
renderSimpleArrayForm({ schema, value: [true, false] });
// Should render form fields (checkboxes)
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes).toHaveLength(2);
});
it("should detect simple object arrays as simple", () => {
const schema = {
type: "array" as const,
items: {
type: "object" as const,
properties: {
name: { type: "string" as const },
age: { type: "number" as const },
},
},
};
renderSimpleArrayForm({ schema, value: [{ name: "John", age: 30 }] });
// Should render form fields for simple objects
expect(screen.getByText("Add Item")).toBeDefined();
expect(screen.getByText("Remove")).toBeDefined();
});
});
describe("Array with Different Item Types", () => {
it("should handle integer array items", () => {
const schema = {
type: "array" as const,
items: { type: "integer" as const },
};
const onChange = jest.fn();
renderSimpleArrayForm({ schema, value: [1, 2], onChange });
const inputs = screen.getAllByRole("spinbutton");
expect(inputs).toHaveLength(2);
expect(inputs[0]).toHaveProperty("value", "1");
expect(inputs[1]).toHaveProperty("value", "2");
// Test adding new integer item
const addButton = screen.getByText("Add Item");
fireEvent.click(addButton);
expect(onChange).toHaveBeenCalledWith([1, 2, 0]);
});
it("should handle boolean array items", () => {
const schema = {
type: "array" as const,
items: { type: "boolean" as const },
};
const onChange = jest.fn();
renderSimpleArrayForm({ schema, value: [true, false], onChange });
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes).toHaveLength(2);
expect(checkboxes[0]).toHaveProperty("checked", true);
expect(checkboxes[1]).toHaveProperty("checked", false);
// Test adding new boolean item
const addButton = screen.getByText("Add Item");
fireEvent.click(addButton);
expect(onChange).toHaveBeenCalledWith([true, false, false]);
});
});
describe("Array Item Descriptions", () => {
it("should show item description when available", () => {
const schema = {
type: "array" as const,
description: "List of names",
items: {
type: "string" as const,
description: "Person name",
},
};
renderSimpleArrayForm({ schema });
expect(screen.getByText("List of names")).toBeDefined();
expect(screen.getByText("Items: Person name")).toBeDefined();
});
it("should use item description in add button title", () => {
const schema = {
type: "array" as const,
items: {
type: "string" as const,
description: "Email address",
},
};
renderSimpleArrayForm({ schema });
const addButton = screen.getByText("Add Item");
expect(addButton).toHaveProperty("title", "Add new Email address");
});
it("should use default title when no item description", () => {
const schema = {
type: "array" as const,
items: { type: "string" as const },
};
renderSimpleArrayForm({ schema });
const addButton = screen.getByText("Add Item");
expect(addButton).toHaveProperty("title", "Add new item");
});
});
});

View file

@ -11,72 +11,68 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
describe("generateDefaultValue", () => {
test("generates default string", () => {
expect(generateDefaultValue({ type: "string", required: true })).toBe("");
const parentSchema = { type: "object" as const, required: ["testProp"] };
expect(
generateDefaultValue({ type: "string" }, "testProp", parentSchema),
).toBe("");
});
test("generates default number", () => {
expect(generateDefaultValue({ type: "number", required: true })).toBe(0);
const parentSchema = { type: "object" as const, required: ["testProp"] };
expect(
generateDefaultValue({ type: "number" }, "testProp", parentSchema),
).toBe(0);
});
test("generates default integer", () => {
expect(generateDefaultValue({ type: "integer", required: true })).toBe(0);
const parentSchema = { type: "object" as const, required: ["testProp"] };
expect(
generateDefaultValue({ type: "integer" }, "testProp", parentSchema),
).toBe(0);
});
test("generates default boolean", () => {
expect(generateDefaultValue({ type: "boolean", required: true })).toBe(
false,
);
const parentSchema = { type: "object" as const, required: ["testProp"] };
expect(
generateDefaultValue({ type: "boolean" }, "testProp", parentSchema),
).toBe(false);
});
test("generates default array", () => {
expect(generateDefaultValue({ type: "array", required: true })).toEqual([]);
expect(generateDefaultValue({ type: "array" })).toEqual([]);
});
test("generates default empty object", () => {
expect(generateDefaultValue({ type: "object", required: true })).toEqual(
{},
);
expect(generateDefaultValue({ type: "object" })).toEqual({});
});
test("generates default null for unknown types", () => {
// @ts-expect-error Testing with invalid type
expect(generateDefaultValue({ type: "unknown", required: true })).toBe(
null,
);
expect(generateDefaultValue({ type: "unknown" })).toBe(undefined);
});
test("generates empty array for non-required array", () => {
expect(generateDefaultValue({ type: "array", required: false })).toEqual(
[],
);
expect(generateDefaultValue({ type: "array" })).toEqual([]);
});
test("generates empty object for non-required object", () => {
expect(generateDefaultValue({ type: "object", required: false })).toEqual(
{},
);
expect(generateDefaultValue({ type: "object" })).toEqual({});
});
test("generates null for non-required primitive types", () => {
expect(generateDefaultValue({ type: "string", required: false })).toBe(
undefined,
);
expect(generateDefaultValue({ type: "number", required: false })).toBe(
undefined,
);
expect(generateDefaultValue({ type: "boolean", required: false })).toBe(
undefined,
);
test("generates undefined for non-required primitive types", () => {
expect(generateDefaultValue({ type: "string" })).toBe(undefined);
expect(generateDefaultValue({ type: "number" })).toBe(undefined);
expect(generateDefaultValue({ type: "boolean" })).toBe(undefined);
});
test("generates object with properties", () => {
const schema: JsonSchemaType = {
type: "object",
required: true,
required: ["name", "age", "isActive"],
properties: {
name: { type: "string", required: true },
age: { type: "number", required: true },
isActive: { type: "boolean", required: true },
name: { type: "string" },
age: { type: "number" },
isActive: { type: "boolean" },
},
};
expect(generateDefaultValue(schema)).toEqual({
@ -89,18 +85,18 @@ describe("generateDefaultValue", () => {
test("handles nested objects", () => {
const schema: JsonSchemaType = {
type: "object",
required: true,
required: ["user"],
properties: {
user: {
type: "object",
required: true,
required: ["name", "address"],
properties: {
name: { type: "string", required: true },
name: { type: "string" },
address: {
type: "object",
required: true,
required: ["city"],
properties: {
city: { type: "string", required: true },
city: { type: "string" },
},
},
},

View file

@ -17,7 +17,7 @@ export type JsonSchemaType = {
| "object"
| "null";
description?: string;
required?: boolean;
required?: string[];
default?: JsonValue;
properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType;

View file

@ -81,46 +81,70 @@ export function hasOutputSchema(toolName: string): boolean {
/**
* Generates a default value based on a JSON schema type
* @param schema The JSON schema definition
* @returns A default value matching the schema type, or null for non-required fields
* @param propertyName Optional property name for checking if it's required in parent schema
* @param parentSchema Optional parent schema to check required array
* @returns A default value matching the schema type
*/
export function generateDefaultValue(schema: JsonSchemaType): JsonValue {
if ("default" in schema) {
export function generateDefaultValue(
schema: JsonSchemaType,
propertyName?: string,
parentSchema?: JsonSchemaType,
): JsonValue {
if ("default" in schema && schema.default !== undefined) {
return schema.default;
}
if (!schema.required) {
if (schema.type === "array") return [];
if (schema.type === "object") return {};
return undefined;
}
// Check if this property is required in the parent schema
const isRequired =
propertyName && parentSchema
? isPropertyRequired(propertyName, parentSchema)
: false;
switch (schema.type) {
case "string":
return "";
return isRequired ? "" : undefined;
case "number":
case "integer":
return 0;
return isRequired ? 0 : undefined;
case "boolean":
return false;
return isRequired ? false : undefined;
case "array":
return [];
case "object": {
if (!schema.properties) return {};
const obj: JsonObject = {};
Object.entries(schema.properties)
.filter(([, prop]) => prop.required)
.forEach(([key, prop]) => {
const value = generateDefaultValue(prop);
obj[key] = value;
});
// Only include properties that are required according to the schema's required array
Object.entries(schema.properties).forEach(([key, prop]) => {
if (isPropertyRequired(key, schema)) {
const value = generateDefaultValue(prop, key, schema);
if (value !== undefined) {
obj[key] = value;
}
}
});
return obj;
}
default:
case "null":
return null;
default:
return undefined;
}
}
/**
* Helper function to check if a property is required in a schema
* @param propertyName The name of the property to check
* @param schema The parent schema containing the required array
* @returns true if the property is required, false otherwise
*/
export function isPropertyRequired(
propertyName: string,
schema: JsonSchemaType,
): boolean {
return schema.required?.includes(propertyName) ?? false;
}
/**
* Formats a field key into a human-readable label
* @param key The field key to format