fix: centralize the nullable-union collapse in core and apply it in the TUI

Closes #2015, folded into #1928 rather than left as a follow-up.

The TUI's `schemaToForm` has the same defect the web `SchemaForm` had: its
local `JsonSchemaProperty` declares only `type?: string` — no `anyOf`, no
array-valued `type` — so a `.nullish()` argument has no top-level `type` or
`enum` for the dispatcher to read and falls to the `switch` default, losing
its select. Milder than the web bug (a plain text field, not a corrupting
JSON textarea), but the same class.

Fixing it in a second place is the argument for not having the logic in a
first place. So the collapse moves to `core/json/nullableUnion.ts` as
`normalizeNullableUnion`, generic over a minimal `NullableUnionSchema`
shape — the least a caller must expose to be *recognized* as a nullable
union — so neither client has to adopt the other's schema type. Both form
builders now share one copy and cannot drift on which schemas they render.

- `core/json/nullableUnion.ts` — the shared collapse, plus tests at
  `clients/web/src/test/core/nullableUnion.test.ts` (the web suite is what
  gates `core/`).
- `clients/web`: `schemaUtils.normalizeUnionType` is gone; `SchemaForm` and
  `resolveRefsInMessage` call the core function directly.
- `clients/tui`: `schemaToForm` normalizes each property before dispatch,
  and `JsonSchemaProperty` grows the `anyOf` / array-`type` fields the
  encoding uses.

Verified by driving the built TUI under a pty against the
`nullable-fields-http.json` showcase server. Before, every argument of
`record_shipment` rendered as a plain text field; after, `direction` is a
select ("No value"), `quantity` an integer, `express` a boolean
("[Not set]").

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 16:59:37 -04:00
commit 17a4c78dd3
4 changed files with 519 additions and 311 deletions

View file

@ -12,7 +12,7 @@ import { useState } from "react";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import { useValueChange } from "../../../hooks/useValueChange";
import type { InspectorFormSchema } from "../../../utils/jsonUtils";
import { normalizeUnionType } from "../../../utils/schemaUtils";
import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js";
const FieldLabel = Text.withProps({
fw: 500,
@ -224,7 +224,7 @@ export function SchemaForm({
// without this an "optional and explicitly nullable" field — what Zod's
// `.nullish()` emits — matches nothing and falls through to the raw-JSON
// fallback, which is unusable for a value the user has to type (#1928).
const fieldSchema = normalizeUnionType(rawSchema);
const fieldSchema = normalizeNullableUnion(rawSchema);
const isRequired = requiredFields.includes(fieldName);
const label = fieldSchema.title ?? fieldName;
const description = fieldSchema.description;

View file

@ -0,0 +1,250 @@
import { describe, it, expect } from "vitest";
import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js";
// The two JSON Schema encodings of "this type, or null". Both form builders —
// the web `SchemaForm` and the TUI's `schemaToForm` — dispatch on a single
// `type` string, so this collapse is what keeps a nullable field renderable
// (#1928 in the web client, #2015 in the TUI).
describe("normalizeNullableUnion", () => {
it("normalizes anyOf string|null", () => {
expect(
normalizeNullableUnion({
anyOf: [{ type: "string" }, { type: "null" }],
}),
).toEqual({
type: "string",
anyOf: undefined,
nullable: true,
});
});
it("normalizes anyOf boolean|null", () => {
expect(
normalizeNullableUnion({
anyOf: [{ type: "boolean" }, { type: "null" }],
}),
).toEqual({
type: "boolean",
anyOf: undefined,
nullable: true,
});
});
it("normalizes anyOf number|null", () => {
expect(
normalizeNullableUnion({
anyOf: [{ type: "number" }, { type: "null" }],
}),
).toEqual({
type: "number",
anyOf: undefined,
nullable: true,
});
});
it("normalizes anyOf integer|null", () => {
expect(
normalizeNullableUnion({
anyOf: [{ type: "integer" }, { type: "null" }],
}),
).toEqual({
type: "integer",
anyOf: undefined,
nullable: true,
});
});
it("normalizes anyOf array|null", () => {
expect(
normalizeNullableUnion({
anyOf: [{ type: "array" }, { type: "null" }],
}),
).toEqual({
type: "array",
anyOf: undefined,
nullable: true,
});
});
it("normalizes type array string|null", () => {
expect(normalizeNullableUnion({ type: ["string", "null"] })).toEqual({
type: "string",
nullable: true,
});
});
it("normalizes type array boolean|null", () => {
expect(normalizeNullableUnion({ type: ["boolean", "null"] })).toEqual({
type: "boolean",
nullable: true,
});
});
it("normalizes type array number|null", () => {
expect(normalizeNullableUnion({ type: ["number", "null"] })).toEqual({
type: "number",
nullable: true,
});
});
it("normalizes type array integer|null", () => {
expect(normalizeNullableUnion({ type: ["integer", "null"] })).toEqual({
type: "integer",
nullable: true,
});
});
it("returns schema unchanged when no union pattern matches", () => {
const schema = { type: "string" as const };
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("ignores anyOf with more than two members", () => {
const schema = {
anyOf: [
{ type: "string" as const },
{ type: "null" as const },
{ type: "number" as const },
],
};
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("ignores a two-member anyOf with no null branch", () => {
const schema = {
anyOf: [{ type: "string" as const }, { type: "number" as const }],
};
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("ignores an anyOf whose non-null branch has no renderable type (#1928)", () => {
const schema = {
anyOf: [{ const: "only" }, { type: "null" as const }],
};
expect(normalizeNullableUnion(schema)).toBe(schema);
});
// #1928: the enum lives on the surviving branch, and hoisting it is what makes
// the field render as a Select instead of the raw-JSON fallback.
it("hoists enum out of an anyOf string-enum|null branch", () => {
expect(
normalizeNullableUnion({
description: "Direction",
anyOf: [
{ type: "string", enum: ["envio", "recebimento"] },
{ type: "null" },
],
}),
).toEqual({
type: "string",
description: "Direction",
enum: ["envio", "recebimento"],
anyOf: undefined,
nullable: true,
});
});
it("infers string for a typeless enum branch", () => {
expect(
normalizeNullableUnion({
anyOf: [{ enum: ["a", "b"] }, { type: "null" }],
}),
).toEqual({
type: "string",
enum: ["a", "b"],
anyOf: undefined,
nullable: true,
});
});
it("hoists items out of an anyOf array|null branch", () => {
expect(
normalizeNullableUnion({
anyOf: [
{ type: "array", items: { type: "string", enum: ["a", "b"] } },
{ type: "null" },
],
}),
).toEqual({
type: "array",
items: { type: "string", enum: ["a", "b"] },
anyOf: undefined,
nullable: true,
});
});
it("hoists properties out of an anyOf object|null branch", () => {
expect(
normalizeNullableUnion({
anyOf: [
{ type: "object", properties: { a: { type: "string" } } },
{ type: "null" },
],
}),
).toEqual({
type: "object",
properties: { a: { type: "string" } },
anyOf: undefined,
nullable: true,
});
});
it("normalizes anyOf object|null regardless of branch order", () => {
expect(
normalizeNullableUnion({
anyOf: [{ type: "null" }, { type: "object" }],
}),
).toEqual({ type: "object", anyOf: undefined, nullable: true });
});
it("normalizes type array object|null", () => {
expect(
normalizeNullableUnion({
type: ["object", "null"],
properties: { a: { type: "string" } },
}),
).toEqual({
type: "object",
properties: { a: { type: "string" } },
nullable: true,
});
});
it("normalizes type array array|null", () => {
expect(normalizeNullableUnion({ type: ["array", "null"] })).toEqual({
type: "array",
nullable: true,
});
});
it("keeps a type array of two non-null members unchanged", () => {
const schema = { type: ["string", "number"] as const };
expect(
normalizeNullableUnion({ ...schema, type: [...schema.type] }),
).toEqual({
type: ["string", "number"],
});
});
// A server can put anything in `anyOf`; a non-object member must not be read
// as a branch (nor throw), it simply means no nullable union was recognized.
it("ignores an anyOf holding a non-object member", () => {
const schema = { anyOf: ["string", { type: "null" as const }] };
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("ignores an anyOf holding an array member", () => {
const schema = { anyOf: [["string"], { type: "null" as const }] };
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("ignores a type array whose non-null member has no widget", () => {
const schema = { type: ["null", "null"] };
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("ignores a schema with no union keywords at all", () => {
const schema = { type: "object" as const, properties: {} };
expect(normalizeNullableUnion(schema)).toBe(schema);
});
});

View file

@ -8,7 +8,6 @@ import {
generateDefaultValue,
isPropertyRequired,
resolveRef,
normalizeUnionType,
formatFieldLabel,
resolveRefsInMessage,
} from "./schemaUtils";
@ -243,226 +242,6 @@ describe("resolveRef", () => {
});
});
describe("normalizeUnionType", () => {
it("normalizes anyOf string|null", () => {
expect(
normalizeUnionType({
anyOf: [{ type: "string" }, { type: "null" }],
}),
).toEqual({
type: "string",
anyOf: undefined,
nullable: true,
});
});
it("normalizes anyOf boolean|null", () => {
expect(
normalizeUnionType({
anyOf: [{ type: "boolean" }, { type: "null" }],
}),
).toEqual({
type: "boolean",
anyOf: undefined,
nullable: true,
});
});
it("normalizes anyOf number|null", () => {
expect(
normalizeUnionType({
anyOf: [{ type: "number" }, { type: "null" }],
}),
).toEqual({
type: "number",
anyOf: undefined,
nullable: true,
});
});
it("normalizes anyOf integer|null", () => {
expect(
normalizeUnionType({
anyOf: [{ type: "integer" }, { type: "null" }],
}),
).toEqual({
type: "integer",
anyOf: undefined,
nullable: true,
});
});
it("normalizes anyOf array|null", () => {
expect(
normalizeUnionType({
anyOf: [{ type: "array" }, { type: "null" }],
}),
).toEqual({
type: "array",
anyOf: undefined,
nullable: true,
});
});
it("normalizes type array string|null", () => {
expect(normalizeUnionType({ type: ["string", "null"] })).toEqual({
type: "string",
nullable: true,
});
});
it("normalizes type array boolean|null", () => {
expect(normalizeUnionType({ type: ["boolean", "null"] })).toEqual({
type: "boolean",
nullable: true,
});
});
it("normalizes type array number|null", () => {
expect(normalizeUnionType({ type: ["number", "null"] })).toEqual({
type: "number",
nullable: true,
});
});
it("normalizes type array integer|null", () => {
expect(normalizeUnionType({ type: ["integer", "null"] })).toEqual({
type: "integer",
nullable: true,
});
});
it("returns schema unchanged when no union pattern matches", () => {
const schema = { type: "string" as const };
expect(normalizeUnionType(schema)).toBe(schema);
});
it("ignores anyOf with more than two members", () => {
const schema = {
anyOf: [
{ type: "string" as const },
{ type: "null" as const },
{ type: "number" as const },
],
};
expect(normalizeUnionType(schema)).toBe(schema);
});
it("ignores a two-member anyOf with no null branch", () => {
const schema = {
anyOf: [{ type: "string" as const }, { type: "number" as const }],
};
expect(normalizeUnionType(schema)).toBe(schema);
});
it("ignores an anyOf whose non-null branch has no renderable type (#1928)", () => {
const schema = {
anyOf: [{ const: "only" }, { type: "null" as const }],
};
expect(normalizeUnionType(schema)).toBe(schema);
});
// #1928: the enum lives on the surviving branch, and hoisting it is what makes
// the field render as a Select instead of the raw-JSON fallback.
it("hoists enum out of an anyOf string-enum|null branch", () => {
expect(
normalizeUnionType({
description: "Direction",
anyOf: [
{ type: "string", enum: ["envio", "recebimento"] },
{ type: "null" },
],
}),
).toEqual({
type: "string",
description: "Direction",
enum: ["envio", "recebimento"],
anyOf: undefined,
nullable: true,
});
});
it("infers string for a typeless enum branch", () => {
expect(
normalizeUnionType({
anyOf: [{ enum: ["a", "b"] }, { type: "null" }],
}),
).toEqual({
type: "string",
enum: ["a", "b"],
anyOf: undefined,
nullable: true,
});
});
it("hoists items out of an anyOf array|null branch", () => {
expect(
normalizeUnionType({
anyOf: [
{ type: "array", items: { type: "string", enum: ["a", "b"] } },
{ type: "null" },
],
}),
).toEqual({
type: "array",
items: { type: "string", enum: ["a", "b"] },
anyOf: undefined,
nullable: true,
});
});
it("hoists properties out of an anyOf object|null branch", () => {
expect(
normalizeUnionType({
anyOf: [
{ type: "object", properties: { a: { type: "string" } } },
{ type: "null" },
],
}),
).toEqual({
type: "object",
properties: { a: { type: "string" } },
anyOf: undefined,
nullable: true,
});
});
it("normalizes anyOf object|null regardless of branch order", () => {
expect(
normalizeUnionType({
anyOf: [{ type: "null" }, { type: "object" }],
}),
).toEqual({ type: "object", anyOf: undefined, nullable: true });
});
it("normalizes type array object|null", () => {
expect(
normalizeUnionType({
type: ["object", "null"],
properties: { a: { type: "string" } },
}),
).toEqual({
type: "object",
properties: { a: { type: "string" } },
nullable: true,
});
});
it("normalizes type array array|null", () => {
expect(normalizeUnionType({ type: ["array", "null"] })).toEqual({
type: "array",
nullable: true,
});
});
it("keeps a type array of two non-null members unchanged", () => {
const schema = { type: ["string", "number"] as const };
expect(normalizeUnionType({ ...schema, type: [...schema.type] })).toEqual({
type: ["string", "number"],
});
});
});
describe("formatFieldLabel", () => {
it("inserts spaces before capitals and capitalizes first letter", () => {
expect(formatFieldLabel("firstName")).toBe("First Name");

View file

@ -3,6 +3,7 @@ import Ajv from "ajv";
import type { ValidateFunction } from "ajv";
import type { Tool, JSONRPCMessage } from "@modelcontextprotocol/client";
import { isJSONRPCRequest } from "@modelcontextprotocol/client";
import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js";
const ajv = new Ajv();
@ -200,87 +201,6 @@ export function resolveRef(
return schema;
}
/**
* The `type` values {@link SchemaForm} dispatches on. `"null"` is deliberately
* absent: it is the branch a nullable union is being flattened *away* from, and
* there is no widget for a field whose only permitted value is `null`.
*/
const RENDERABLE_TYPES = [
"string",
"number",
"integer",
"boolean",
"array",
"object",
] as const;
type RenderableType = (typeof RENDERABLE_TYPES)[number];
function isRenderableType(type: unknown): type is RenderableType {
return RENDERABLE_TYPES.includes(type as RenderableType);
}
/**
* Normalizes a nullable union (`string|null` from FastMCP, or anything Zod's
* `.nullish()` / `.nullable()` compiles to) into the plain type the form
* renderer dispatches on.
*
* Two encodings mean the same thing and are both flattened to
* `{ type: <T>, nullable: true }`:
*
* - `anyOf: [<branch>, { type: "null" }]` — the branch's *own* keywords are
* hoisted onto the result, because that is where the detail the renderer
* needs lives. A nullable enum compiles to
* `anyOf: [{ type: "string", enum: [...] }, { type: "null" }]`, so hoisting
* `enum` is what makes it a `Select` instead of falling through to the raw
* JSON fallback (#1928). The branch also wins over the wrapper on any shared
* key, matching v1.x's behavior.
* - `type: [<T>, "null"]` — the keywords already sit at the top level, so only
* `type` collapses.
*
* Anything else — a union of two real types, a three-member `anyOf`, a branch
* whose type has no widget — is returned untouched and renders through the
* JSON fallback, which is the honest representation of a shape the form cannot
* model.
*
* @param schema The JSON schema to normalize
* @returns A normalized schema, or the original schema when no nullable-union
* pattern matches
*/
export function normalizeUnionType(
schema: InspectorFormSchema,
): InspectorFormSchema {
if (schema.anyOf && schema.anyOf.length === 2) {
// `JsonSchemaConst` is structurally a subset of `InspectorFormSchema` (it
// just carries no `type`), so reading both as the latter is safe.
const branches = schema.anyOf as InspectorFormSchema[];
const nullBranch = branches.find((branch) => branch?.type === "null");
const branch = branches.find((candidate) => candidate?.type !== "null");
if (nullBranch && branch) {
// A bare `{ enum: [...] }` branch carries no `type`; JSON Schema allows
// that, and every value such a schema admits here is a string.
const type = branch.type ?? (branch.enum ? "string" : undefined);
if (isRenderableType(type)) {
return { ...schema, ...branch, type, anyOf: undefined, nullable: true };
}
}
}
if (
Array.isArray(schema.type) &&
schema.type.length === 2 &&
schema.type.includes("null")
) {
const type = schema.type.find((member) => member !== "null");
if (isRenderableType(type)) {
return { ...schema, type, nullable: true };
}
}
return schema;
}
/**
* Formats a field key into a human-readable label
* @param key The field key to format
@ -319,7 +239,7 @@ export function resolveRefsInMessage(message: JSONRPCMessage): JSONRPCMessage {
Object.entries(requestedSchema.properties).map(
([key, propSchema]) => {
const resolved = resolveRef(propSchema, requestedSchema);
const normalized = normalizeUnionType(resolved);
const normalized = normalizeNullableUnion(resolved);
return [key, normalized];
},
),