fix: render nullable enum fields as a Select instead of a raw JSON textarea

A tool argument that is optional AND explicitly nullable — Zod's
`.nullish()` / `.nullable().optional()` — compiles to
`anyOf: [{ type: "string", enum: [...] }, { type: "null" }]`. Every branch
of the tool-call form's field dispatcher tests a single `type` string, so
such a field matched nothing and fell through to the raw-JSON fallback,
whose onChange stores the unparseable in-progress text back into state and
re-`JSON.stringify`s it on the next render — compounding a fresh layer of
escaping on every keystroke.

`normalizeUnionType` already existed for the FastMCP `string|null` shape,
but it only copied the surviving branch's `type` and dropped everything
else, so the `enum` never reached the dispatcher; and nothing called it on
a tool input schema — only elicitation requests, via
`resolveRefsInMessage`. So:

- Rewrite `normalizeUnionType` to hoist the non-null branch's own keywords
  (`enum`, `items`, `properties`, `minimum`, …) onto the flattened schema,
  as v1.x did. This also collapses nine near-identical blocks into two and
  picks up the `object`/`array` cases the `type: [X, "null"]` form was
  missing.
- Normalize each field in `SchemaForm.renderField`, so tool, elicitation,
  and app forms all get it rather than just elicitation.
- Give a nullable enum `Select` a clear affordance, so a value can be set
  back to `null`; a non-nullable enum keeps none, since it would reject it.

Adds a `record_shipment` preset and `nullable-fields-http.json` showcase
config serving all four nullable scalar shapes, for manual verification.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 16:10:17 -04:00
commit 937f1dd51b
2 changed files with 428 additions and 84 deletions

View file

@ -12,6 +12,7 @@ import type {
} from "./composable-test-server.js";
import {
createEchoTool,
createNullableFieldsTool,
createGetEnvTool,
createAddTool,
createGetSumTool,
@ -91,6 +92,9 @@ function resolveToolPreset(
switch (name) {
case "echo":
return createEchoTool();
case "record_shipment":
case "nullable_fields":
return createNullableFieldsTool();
case "get-env":
case "get_env":
return createGetEnvTool();

View file

@ -162,6 +162,39 @@ export function createEchoTool(): ToolDefinition {
};
}
/**
* Create a "record_shipment" tool whose arguments are all **nullable** — the
* shape Zod's `.nullish()` / `.nullable()` compiles to, where the real type
* sits inside `anyOf: [<branch>, { type: "null" }]` rather than at the top
* level.
*
* `direction` is the case from #1928: a nullable *enum*, whose `enum` keyword
* lives on the surviving branch. Before the fix nothing in the tool-call form
* matched such a field, so it fell through to the raw-JSON textarea and
* re-escaped its own value on every keystroke. The other three cover the
* remaining scalar branches so a regression in one is visible next to a
* working sibling.
*/
export function createNullableFieldsTool(): ToolDefinition {
return {
name: "record_shipment",
description:
"Record a shipment. Every argument is optional AND explicitly nullable (Zod .nullish()), so each compiles to an anyOf union with a null branch.",
inputSchema: {
direction: z
.enum(["envio", "recebimento"])
.nullish()
.describe("Direction of the shipment (nullable enum — #1928)"),
reference: z.string().nullish().describe("Free-text reference"),
quantity: z.number().int().nullish().describe("Number of packages"),
express: z.boolean().nullish().describe("Ship express"),
},
handler: async (params: Record<string, unknown>) => {
return toToolResult(JSON.stringify(params, null, 2));
},
};
}
/**
* Create a "get-env" tool matching @modelcontextprotocol/server-everything.
* Returns the server process environment as pretty-printed JSON text.