Skip to content

Nullable enum fields (anyOf with null branch) fall back to broken raw-JSON Textarea — regression from v1.x #1928

Description

@vanessalimaf

Summary

A tool inputSchema property that is a nullable enum — i.e. compiles to
{ anyOf: [{ type: "string", enum: [...] }, { type: "null" }] }, which is
the standard JSON Schema representation for "optional AND explicitly
nullable enum" (e.g. via Zod's .nullish() / .nullable().optional()) —
is not recognized by the tool-call form's field dispatcher. Instead of
rendering a <Select>, it falls through to the generic raw-JSON
Textarea fallback, whose onChange handler corrupts the value on every
keystroke.

This used to work correctly in v1.x (v1-latest, currently 1.0.1) and
appears to have been lost in the v2 rewrite — see "Regression evidence"
below.

Steps to reproduce

  1. Register a tool with an input schema field defined in Zod as:
    type: z.enum(['envio', 'recebimento']).nullish()
    which produces this JSON Schema:
    {
      "anyOf": [
        { "type": "string", "enum": ["envio", "recebimento"] },
        { "type": "null" }
      ]
    }
  2. Open the tool in the Inspector web client (@modelcontextprotocol/inspector@2.1.0) and try to fill in that field.

Expected

A <Select> dropdown with envio / recebimento as options (optionally with a "none" choice for null) — the same as when the field is a plain z.enum([...]).optional() (no null branch).

Actual

  • No dropdown is rendered. The field's description renders correctly above it, but the value control renders as a generic Textarea.
  • Typing into it corrupts the value on every keystroke: each character triggers JSON.parse(currentText), which fails (the in-progress text isn't valid JSON yet), and the catch branch stores the raw unparsed text back into state. The next render redisplays that raw text via JSON.stringify(value, null, 2), adding a fresh layer of escaping on top of the already-invalid text. This repeats every keystroke, producing an exponentially escaped string, e.g. after a few characters:
    "\"\\\"\\\\\\\"\\\\\\\\\\\\\\\"...
    

Root cause (client/src, form field dispatcher)

In the built bundle (clients/web/dist/assets/index-*.js), the per-field renderer only checks n.oneOf and n.items?.anyOf (for arrays) — never n.anyOf on a scalar field:

function s(e, n) {
  ...
  if (n.type === 'string' && n.enum) return <Select .../>;
  if (n.type === 'string' && n.oneOf) return <Select .../>;
  if (n.type === 'string') return <TextInput .../>;
  ...
  if (n.type === 'array' && n.items?.enum) return <MultiSelect .../>;
  if (n.type === 'array' && n.items?.anyOf) return <MultiSelect .../>;
  return n.type === 'object' && n.properties
    ? <nested form/>
    : <Textarea                                              // ← fallback hit here
        value={l === undefined ? '' : JSON.stringify(l, null, 2)}
        onChange={t => { try { o(e, JSON.parse(t)) } catch { o(e, t) } }}
      />;
}

Since our field has no top-level type/enum/oneOf (they're nested inside anyOf), every branch is skipped and it falls to the Textarea, whose onChange conflates "value for display" (always re-JSON.stringifyd) with "value for storage" (raw text on parse failure) — that mismatch is what causes the compounding corruption.

Regression evidence (v1.0.1 handled this correctly)

@modelcontextprotocol/inspector@1.0.1 (v1-latest dist-tag) has a schema-normalization step that runs before widget dispatch, specifically for this case:

if (schema.anyOf.some(t => t.type === "null")) {
  const nonNullItem = schema.anyOf.find(item => item?.type !== "null");
  if (nonNullItem?.type || nonNullItem?.enum) {
    return {
      ...schema,
      ...nonNullItem,
      type: nonNullItem?.type || (nonNullItem?.enum ? "string" : void 0),
      nullable: true,
      anyOf: void 0
    };
  }
}
// + equivalent handling for `"type": ["string","null"]`, `["boolean","null"]`, etc.

This flattens the anyOf into a plain { type: "string", enum: [...], nullable: true }, which correctly matches the type === 'string' && enum branch and renders the dropdown. This normalization does not appear to exist anymore in the v2 client.

Suggested fix

Reintroduce an equivalent anyOf/nullable-flattening normalization step in the v2 field dispatcher (or wherever the schema is pre-processed) before the if (n.type === ...) chain runs, mirroring the v1.0.1 logic above.

Environment

  • @modelcontextprotocol/inspector: 2.1.0 (latest)
  • Reproduced by inspecting the published bundle directly; same result expected via the UI (Textarea fallback + escaping on typed input)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv2Issues and PRs for v2

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions