Skip to content

fix: render nested/Optional arrays as structured forms & align string field style with ToolsTab#1258

Open
Ahmed-Islam-AI wants to merge 3 commits into
modelcontextprotocol:mainfrom
Ahmed-Islam-AI:fix/nested-schema-array-rendering
Open

fix: render nested/Optional arrays as structured forms & align string field style with ToolsTab#1258
Ahmed-Islam-AI wants to merge 3 commits into
modelcontextprotocol:mainfrom
Ahmed-Islam-AI:fix/nested-schema-array-rendering

Conversation

@Ahmed-Islam-AI
Copy link
Copy Markdown

@Ahmed-Islam-AI Ahmed-Islam-AI commented Apr 28, 2026

Problem

Two related rendering gaps in DynamicJsonForm:

  1. Nested / Optional array schemas rendered as raw JSON boxes. FastMCP (and any Pydantic v2 tool) emits schemas like:
    • List[T]{ type: "array", items: { type: "object", properties: {...} } }
    • Optional[List[str]]{ anyOf: [{ type: "array", items: {...} }, { type: "null" }] }
    • List[Optional[T]] → items wrapped in anyOf: [object, null]

All three fell back to a raw JSON editor instead of structured Add/Remove controls.

  1. String field height mismatch.
    DynamicJsonForm rendered plain-text strings as <Input> (single-line, 36 px), while ToolsTab renders direct-parameter strings as <Textarea> — visually inconsistent.

Solution

DynamicJsonForm.tsx

  • canRenderTopLevelForm — calls normalizeUnionType(s) first so anyOf:[array,null] top-level schemas enable form mode.
  • renderFormFields — calls normalizeUnionType(propSchema) at the top of every recursive call so anyOf:[X,null] fields resolve to their real type before any switch/depth check.
  • Array case "array" — normalizes propSchema.items through normalizeUnionType; gates structured rendering on isSimpleObject(itemSchema) || (itemSchema.type === "object" && !!itemSchema.properties) so arrays of objects get Add/Remove controls instead of falling back to JSON.
  • String case "string" — plain text now renders as <Textarea> (matching ToolsTab); special formats (email, uri, date, date-time) keep <Input type="...">.
  • Boolean case "boolean" — replaced <input type="checkbox"> with the shadcn <Switch> component for a cleaner toggle UI; description renders above the toggle.
  • Description trimming — trims propSchema.description to strip leading/trailing whitespace from Python triple-quoted docstrings.
  • Object-array card layout — Remove button sits at the bottom-right of each object card instead of the top.

Tests

  • DynamicJsonForm.test.tsx — updated string assertions from type="text"type="textarea", boolean assertions from role="checkbox"role="switch" / aria-checked, querySelector("input")querySelector("textarea"), removed inapplicable pattern test.
  • DynamicJsonForm.array.test.tsx — replaced legacy "Complex Array Fallback" block with four targeted cases covering: object arrays, untyped fallback, Optional[List[X]], and List[Optional[X]]; updated boolean array assertions to role="switch".

Test plan

  • cd client && npm test — all 63 tests pass
  • npm run lint passes with no new warnings
  • Open a FastMCP tool with List[SomeModel] parameter → fields render as structured form with Add/Remove
  • Open a FastMCP tool with Optional[List[str]] → same structured form, not a JSON box
  • Plain-text string fields visually match the height of direct-parameter string inputs
  • Boolean fields render as a toggle switch with description above
  • Special string formats (email, date, etc.) still render as typed <input> elements
  • "Switch to JSON / Switch to Form" toggle still works correctly

@Ahmed-Islam-AI
Copy link
Copy Markdown
Author

Visual proof of the fix

Before — nested object arrays fall back to a raw JSON box

before

Both user (List[User]) and extras (Optional[List[Extra]]) showed a raw [] textarea with no structure, making it impossible to fill in individual fields.

After — structured form fields with Add / Remove

  1. Screen Shot 1
image
  1. Screen Shot 2
image

Each array field now renders typed input fields for every property, with Add Item to append a new entry and Remove to delete one.
This works for:

  • List[Model] — direct array of objects
  • Optional[List[Model]] — Pydantic v2 generates anyOf:[array, null] which was previously not recognized as a form-renderable type

- Replace <Input type="text"> with <Textarea> for plain-text string
  properties in DynamicJsonForm, matching the height and style of
  direct-parameter string inputs in ToolsTab
- Special-format strings (email, uri, date, date-time) keep <Input>
  with their appropriate type attribute
- Boolean fields already used <Switch>; update tests to query
  role="switch" / aria-checked instead of role="checkbox"
- Update test assertions to reflect the textarea element type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Ahmed-Islam-AI Ahmed-Islam-AI changed the title fix: render nested object arrays and Optional arrays as structured forms fix: render nested/Optional arrays as structured forms & align string field style with ToolsTab Apr 29, 2026
@Ahmed-Islam-AI
Copy link
Copy Markdown
Author

Update — additional UI polish commits

After the initial fix for nested/Optional array rendering, a second round of improvements was added to align DynamicJsonForm more closely with the rest of the inspector UI:

String fields → <Textarea>

Plain-text string properties now render as <Textarea> instead of <Input type="text">, matching the height and style of the direct-parameter inputs in ToolsTab. Special formats (email, uri, date, date-time) are unaffected and keep their typed <Input> element.

Boolean fields → <Switch>

boolean properties now render as a shadcn <Switch> toggle with a True / False label, replacing the bare <input type="checkbox">. The field description renders above the toggle.

Description whitespace trimming

propSchema.description is trimmed before display, so Python triple-quoted docstrings with leading/trailing newlines and indentation no longer appear with extra whitespace in the form.

Test updates

  • DynamicJsonForm.test.tsx — string assertions updated to reflect <textarea> (type="textarea", querySelector("textarea")); boolean assertions updated to role="switch" / aria-checked; removed pattern test (not applicable to <textarea>).
  • DynamicJsonForm.array.test.tsx — boolean array assertions updated to role="switch" / aria-checked.

All 63 tests pass after these changes.

image

@modelcontextprotocol modelcontextprotocol deleted a comment from claude Bot May 2, 2026
Copy link
Copy Markdown
Member

@cliffhall cliffhall left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Overview

The PR fixes two real, user-visible issues in DynamicJsonForm:

  1. FastMCP/Pydantic v2 schemas (List[Model], Optional[List[T]], List[Optional[T]]) were falling back to a raw JSON editor instead of getting structured Add/Remove controls. The fix routes those schemas through the existing normalizeUnionType helper before type dispatch.
  2. Plain string fields in DynamicJsonForm rendered as single-line <Input> while ToolsTab renders direct-parameter strings as <Textarea> — a visible mismatch. Now both use <Textarea>.

Also swaps the boolean checkbox for shadcn <Switch> and tidies description whitespace.

The core approach is sound: normalizing anyOf:[X,null] at the top of renderFormFields and on propSchema.items cleanly handles all three Optional/nested patterns with a single helper instead of scattering special-cases through the type switch.

What's done well

  • normalizeUnionType is applied at exactly the right entry points — top of renderFormFields and on propSchema.items — so Optional[List[T]], List[Optional[T]], and top-level Optional[Array] all converge on the same code paths.
  • The new array case correctly distinguishes "items are an object with properties" from isSimpleObject items, and the per-card layout with a bottom-right Remove button is reasonable.
  • The new tests are targeted and meaningful — the four cases in "Complex Array Rendering" precisely cover the bugs the PR claims to fix, and the union-type test (anyOf:[array,null]) is the kind of test that would have caught this regression in the first place.
  • Style alignment with ToolsTab.tsx is verified — that component does render prop.type === "string" as <Textarea>, so this PR brings the nested form into line.

Issues to address

1. Regression: pattern validation silently dropped for plain strings (correctness)

Plain-text strings switched from <Input pattern={...}> to <Textarea>, which has no pattern attribute. The PR removes the corresponding test with a comment explaining <textarea> doesn't support it, but schema.pattern constraints from server-side tool schemas will now be ignored client-side — a real loss of validation that isn't called out in the PR description.

Suggested fix: keep <Input type=\"text\"> when propSchema.pattern is present, or implement pattern checking manually (onBlur validation against new RegExp(propSchema.pattern)).

2. Regression: top-level array description disappears

The diff removes these blocks from the structured array case:

- {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>
- )}

Two test assertions for \"Test array field\" and \"Items: Array item\" were correspondingly deleted. The PR description doesn't mention this. For top-level array schemas (where there's no parent object label upstream to surface the description), users will lose this context.

Suggested fix: restore both blocks above the items list, or move into the per-card object header.

3. required attribute dropped on booleans (minor)

Previous <Input type=\"checkbox\" required={isRequired}> is replaced with <Switch>, which doesn't accept required. Usually inconsequential for booleans (false is a valid value), but inconsistent with the rest of the form. Probably acceptable, but worth a deliberate decision rather than a silent drop.

4. Parameter reassignment style (minor)

In renderFormFields, both propSchema = normalizeUnionType(propSchema) and the description-trim block reassign the function parameter. Functionally fine, but a local const normalizedSchema = ... would read more clearly given the function is ~300 lines long.

5. "True"/"False" label (minor UX)

The new <span> next to the Switch reads \"True\" : \"False\". Most toggle UIs either omit a value label entirely (relying on the toggle's on/off visual + a separate field label) or use lowercase/sentence-case. Capitalized "True"/"False" reads more like a Python repr than a UI element.

Smaller observations

  • The specialFormatMap refactor (typed Record<SpecialFormat, string>) is a nice tightening — restricts inputType to known good values.
  • Description-trimming is reasonable defensive handling for Pydantic docstrings (\"\"\"\n text \n\"\"\"). Allocating a new schema object on every render in a recursive function is wasteful but negligible at typical form sizes; not worth optimizing.
  • canRenderTopLevelForm now uses normalizeUnionType correctly. Note this means Optional[primitive] at the top level becomes form-capable — that's the desired behavior but worth confirming with a test (none added).
  • Test changes around role=\"switch\" / aria-checked are the canonical RTL approach for shadcn Switch.

Risk summary

  • Behavioral — Two silent regressions (pattern validation, array description) that aren't reflected in the PR description. The pattern one in particular has correctness implications if tool authors rely on pattern for input-shape constraints.
  • Test coverage — Good for the bugs being fixed; insufficient for the regressions introduced (no test that pattern is enforced; assertion for the array description was deleted rather than relocated).
  • Performance — No concerns.
  • Security — Loss of client-side pattern validation is not a security boundary (server should always re-validate), but it does weaken UX-level input guardrails.

Recommendation

The core fix is correct and valuable, but the PR should preserve pattern validation and the array description. Items 3–5 are polish and not blocking.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants