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
- 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" }
]
}
- 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)
Summary
A tool
inputSchemaproperty that is a nullable enum — i.e. compiles to{ anyOf: [{ type: "string", enum: [...] }, { type: "null" }] }, which isthe 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-JSONTextareafallback, whoseonChangehandler corrupts the value on everykeystroke.
This used to work correctly in v1.x (
v1-latest, currently1.0.1) andappears to have been lost in the v2 rewrite — see "Regression evidence"
below.
Steps to reproduce
{ "anyOf": [ { "type": "string", "enum": ["envio", "recebimento"] }, { "type": "null" } ] }@modelcontextprotocol/inspector@2.1.0) and try to fill in that field.Expected
A
<Select>dropdown withenvio/recebimentoas options (optionally with a "none" choice fornull) — the same as when the field is a plainz.enum([...]).optional()(nonullbranch).Actual
descriptionrenders correctly above it, but the value control renders as a genericTextarea.JSON.parse(currentText), which fails (the in-progress text isn't valid JSON yet), and thecatchbranch stores the raw unparsed text back into state. The next render redisplays that raw text viaJSON.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 checksn.oneOfandn.items?.anyOf(for arrays) — nevern.anyOfon a scalar field:Since our field has no top-level
type/enum/oneOf(they're nested insideanyOf), every branch is skipped and it falls to theTextarea, whoseonChangeconflates "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-latestdist-tag) has a schema-normalization step that runs before widget dispatch, specifically for this case:This flattens the
anyOfinto a plain{ type: "string", enum: [...], nullable: true }, which correctly matches thetype === 'string' && enumbranch 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 theif (n.type === ...)chain runs, mirroring the v1.0.1 logic above.Environment
@modelcontextprotocol/inspector: 2.1.0 (latest)