diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index 6a5993c3..081a7d3b 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -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 ( { 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 ( + { + try { + const parsed = JSON.parse(newValue); + handleFieldChange(path, parsed); + setJsonError(undefined); + } catch (err) { + setJsonError( + err instanceof Error ? err.message : "Invalid JSON", + ); + } + }} + error={jsonError} + /> + ); + } + + return ( +
+ {Object.entries(propSchema.properties).map(([key, subSchema]) => ( +
+ + {renderFormFields( + subSchema as JsonSchemaType, + (currentValue as Record)?.[key], + [...path, key], + depth + 1, + propSchema, + key, + )} +
+ ))} +
+ ); + 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 ( +
+ {propSchema.description && ( +

+ {propSchema.description} +

+ )} + + {propSchema.items?.description && ( +

+ Items: {propSchema.items.description} +

+ )} + +
+ {arrayValue.map((item, index) => ( +
+ {renderFormFields( + propSchema.items as JsonSchemaType, + item, + [...path, index.toString()], + depth + 1, + )} + +
+ ))} + +
+
+ ); + } + + // For complex arrays, fall back to JSON editor + return ( + { + 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; } diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 8a7f6578..cf85e4eb 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -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 (
{prop.type === "boolean" ? (
diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 2130e68b..eb7ff3ba 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -84,12 +84,6 @@ Object.defineProperty(window, "sessionStorage", { value: sessionStorageMock, }); -Object.defineProperty(window, "location", { - value: { - origin: "http://localhost:3000", - }, -}); - describe("AuthDebugger", () => { const defaultAuthState = EMPTY_DEBUGGER_STATE; @@ -104,7 +98,7 @@ describe("AuthDebugger", () => { jest.clearAllMocks(); sessionStorageMock.getItem.mockReturnValue(null); - // Supress + // Suppress console errors in tests to avoid JSDOM navigation noise jest.spyOn(console, "error").mockImplementation(() => {}); mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); @@ -447,18 +441,6 @@ describe("AuthDebugger", () => { it("should store auth state to sessionStorage before redirect in Quick OAuth Flow", async () => { const updateAuthState = jest.fn(); - // Mock window.location.href setter - const originalLocation = window.location; - const locationMock = { - ...originalLocation, - href: "", - origin: "http://localhost:3000", - }; - Object.defineProperty(window, "location", { - writable: true, - value: locationMock, - }); - // Setup mocks for OAuth flow mockStartAuthorization.mockResolvedValue({ authorizationUrl: new URL( diff --git a/client/src/components/__tests__/DynamicJsonForm.array.test.tsx b/client/src/components/__tests__/DynamicJsonForm.array.test.tsx new file mode 100644 index 00000000..00a324bf --- /dev/null +++ b/client/src/components/__tests__/DynamicJsonForm.array.test.tsx @@ -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(); + }; + + 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(); + }; + + 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"); + }); + }); +}); diff --git a/client/src/utils/__tests__/schemaUtils.test.ts b/client/src/utils/__tests__/schemaUtils.test.ts index ca6b65ca..133e25ec 100644 --- a/client/src/utils/__tests__/schemaUtils.test.ts +++ b/client/src/utils/__tests__/schemaUtils.test.ts @@ -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" }, }, }, }, diff --git a/client/src/utils/jsonUtils.ts b/client/src/utils/jsonUtils.ts index 28cbf303..83317938 100644 --- a/client/src/utils/jsonUtils.ts +++ b/client/src/utils/jsonUtils.ts @@ -17,7 +17,7 @@ export type JsonSchemaType = { | "object" | "null"; description?: string; - required?: boolean; + required?: string[]; default?: JsonValue; properties?: Record; items?: JsonSchemaType; diff --git a/client/src/utils/schemaUtils.ts b/client/src/utils/schemaUtils.ts index 30210532..9fc7a724 100644 --- a/client/src/utils/schemaUtils.ts +++ b/client/src/utils/schemaUtils.ts @@ -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