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 9 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 9 commits into
modelcontextprotocol:mainfrom
Ahmed-Islam-AI:fix/nested-schema-array-rendering

Conversation

@Ahmed-Islam-AI

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

Copy link
Copy Markdown

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(...) at the top of every recursive call so anyOf:[X,null] fields resolve to their real type before any switch/depth check. Binds a local from a rawSchema parameter instead of mutating the parameter.
  • 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. The array-level description and the per-item Items: … description render above the items list.
  • String case "string" — plain text now renders as <Textarea> (matching ToolsTab); special formats (email, uri, date, date-time) keep <Input type="...">. A string that declares a pattern keeps <Input type="text" pattern=…> so the constraint is preserved client-side (<textarea> has no pattern).
  • 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.
  • Array default valuesAdd Item on an object array seeds required fields (via generateDefaultValue) instead of {}; switching an empty structured array to JSON seeds one template item so the shape is visible; a top-level array schema defaults to [].

Tests

  • DynamicJsonForm.test.tsx — plain-text strings assert type="textarea"; a pattern-constrained string asserts <input type="text"> carrying the pattern attribute.
  • DynamicJsonForm.array.test.tsx — targeted cases for object arrays, untyped fallback, Optional[List[X]], and List[Optional[X]]; array/item description assertions; object-item default and JSON template-seeding tests.
  • schemaUtils.test.ts — a root-level array schema defaults to [].

Test plan

  • cd client && npm test — full client suite passes
  • npm run lint passes with no new warnings
  • Open a FastMCP tool with List[SomeModel] parameter → fields render as a 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
  • Special string formats (email, date, etc.) still render as typed <input> elements
  • "Switch to JSON / Switch to Form" toggle still works correctly

Update — addressed review feedback

  • pattern validation — pattern-constrained strings now fall back to <Input type="text" pattern> so the constraint is preserved (regression test added).
  • array / item descriptions — restored both description blocks above the items list (assertions re-added rather than deleted).
  • required on booleans + "True"/"False" label — reverted the boolean field from the shadcn <Switch> back to the native <Input type="checkbox" required> the Inspector used previously: it natively supports required (restoring parity with the other fields) and drops the capitalized label. Boolean tests reverted to role="checkbox".
  • parameter reassignmentrenderFormFields no longer mutates its propSchema parameter.

@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

@cliffhall cliffhall left a comment

Copy link
Copy Markdown
Member

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.

Ahmed-Islam-AI and others added 6 commits June 30, 2026 16:15
Plain-text strings render as <Textarea> to match ToolsTab, but
<textarea> has no `pattern` attribute, so a string schema's `pattern`
constraint was silently dropped client-side. Fall back to
<Input type="text"> when `propSchema.pattern` is present so the
constraint is preserved (minLength/maxLength/required carried along).

Adds a regression test asserting a pattern-constrained string renders
an <input> carrying the pattern attribute.

Addresses review item modelcontextprotocol#1 on modelcontextprotocol#1258.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The structured array case dropped the array-level description and the
"Items: <item description>" line. For top-level array schemas there is
no parent object label upstream, so users lost that context. Restore
both blocks above the items list.

Re-adds the assertions (relocated into the existing test) verifying the
array description and the per-item "Items:" line render.

Addresses review item modelcontextprotocol#2 on modelcontextprotocol#1258.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The PR had swapped the boolean <Input type="checkbox"> for a shadcn
<Switch>, which dropped the `required` attribute (Switch had none) and
added a capitalized "True"/"False" label. Revert to the native checkbox
that the Inspector used previously: it natively supports `required`
(restoring parity with every other field) and removes the label, so the
control is consistent with the rest of the form again.

Reverts the boolean test assertions from role="switch"/aria-checked back
to role="checkbox" in both component test files.

Addresses review items modelcontextprotocol#3 (required dropped) and modelcontextprotocol#5 (True/False label)
on modelcontextprotocol#1258 by removing the Switch change that introduced them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
renderFormFields reassigned its `propSchema` parameter (both for
normalizeUnionType and the description trim). Rename the parameter to
`rawSchema` and bind a local `propSchema` instead, so the incoming
parameter is never mutated. Pure refactor; no behavior change.

Addresses review item modelcontextprotocol#4 on modelcontextprotocol#1258.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three small improvements that make the structured array forms usable
out of the box:

- getArrayItemDefault now builds object items via generateDefaultValue,
  so "Add Item" on an object array seeds required fields instead of {}.
- Switching an empty structured array to JSON seeds one template item so
  the expected shape is visible instead of a bare [].
- generateDefaultValue returns [] for a root-level array schema (not
  undefined), so a top-level array renders an empty form with an Add
  button rather than falling back.

Adds tests for the object-item default, the JSON template seeding, and
updates the root-level array default-value test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Ahmed-Islam-AI

Copy link
Copy Markdown
Author

Thanks for the thorough review! Addressed all points:

  • Progress notifications #1 — pattern validation (correctness): Restored. Plain strings still render as <Textarea> to match ToolsTab, but a string field that declares a pattern now falls back to <Input type="text" pattern=…> so the constraint is preserved (minLength/maxLength/required carried along). Added a regression test asserting the <input> carries the pattern attribute.
  • Request logging #2 — array / item descriptions: Restored both the array-level description and the Items: … block above the items list, and relocated the deleted assertions into a real test (should show item description when available).
  • Completion requests #3required on booleans & OAuth support #5 — "True"/"False" label: Reverted the boolean field from <Switch> back to the native <Input type="checkbox" required={isRequired}> the Inspector used previously. This restores required parity with the other fields and removes the capitalized label entirely. Boolean tests reverted to role="checkbox".
  • SSE transport support #4 — parameter reassignment: renderFormFields no longer mutates its parameter — renamed to rawSchema and binds a local propSchema.

I also folded in a few small array-default improvements (with tests): Add Item on an object array now seeds required fields instead of {}, switching an empty structured array to JSON shows one template item, and a top-level array schema defaults to [].

All client lint/typecheck and the full test suite pass locally, and the branch is rebased on the latest main.

@Ahmed-Islam-AI Ahmed-Islam-AI requested a review from cliffhall June 30, 2026 12:43
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