Merge pull request #2014 from modelcontextprotocol/v2/fix/1928-nullable-enum

fix: render nullable and union argument fields correctly in the web form and the TUI
This commit is contained in:
Cliff Hall 2026-08-15 21:03:31 -04:00 • committed by GitHub
commit 71aa4b6b1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 2449 additions and 252 deletions

View file

@ -55,7 +55,15 @@ v2/main/
│ │ # annotation scan/validation + mirrored-param
│ │ # derivation, used by the Tools tab — #1632;
│ │ # plus `Mcp-Param-*` header building for the
│ │ # wire, used by both `tools/call` paths — #1846)
│ │ # wire, used by both `tools/call` paths — #1846;
│ │ # nullableUnion.ts: collapses the two JSON Schema
│ │ # encodings of "T or null" (`anyOf` with a null
│ │ # branch, `type: [T,"null"]`) into a plain type,
│ │ # hoisting the surviving branch's own keywords.
│ │ # Shared by BOTH form builders — web SchemaForm
│ │ # and TUI schemaToForm — since each dispatches on
│ │ # a single `type` string and would otherwise miss
│ │ # a nullable field entirely — #1928/#2015)
│ ├── logging/ # Silent pino logger singleton
│ ├── mcp/ # InspectorClient runtime + state stores
│ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks

View file

@ -32,7 +32,8 @@ inspector/
├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json)
│ ├── auth/ # OAuth: providers, discovery, storage, mid-session recovery (browser/node/remote backends)
│ ├── client/ # Install-level client config (`client.json`): browser-safe parse/validate + Node load/save, remote backend, secrets
│ ├── json/ # JSON + parameter/argument conversion utilities
│ ├── json/ # JSON + parameter/argument conversion utilities, and the nullable-union
│ │ # schema collapse shared by the web and TUI form builders
│ ├── logging/ # Silent pino logger singleton
│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import
│ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalize/canonicalize + all-interfaces/loopback detection)
@ -145,6 +146,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
| `pagination-http.json` | Page-by-page list fetching | [#1721](https://github.com/modelcontextprotocol/inspector/issues/1721) |
| `structured-output-http.json` | Tools tab: a result's `structuredContent` section | [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908) |
| `duplicate-tool-names-http.json` | A `tools/list` that repeats a tool name | [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) |
| `nullable-fields-http.json` | Tools tab: nullable (`anyOf` + `null`) arguments | [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928) |
| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) |
| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) |
| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) |
@ -227,6 +229,14 @@ Connect (default legacy era), open the Tools tab, and type `get` into **Search t
The duplicated copies are appended rather than placed beside their twin on purpose. React matches a leading run of same-key children first, so a head-adjacent duplicate happens to line up and the defect hides; separating the pair is what makes it observable — and it is also the realistic shape, two tool sources concatenated.
#### Nullable arguments
`nullable-fields-http.json` serves `record_shipment`, whose four arguments are each declared with Zod's `.nullish()` — "optional **and** explicitly nullable". That compiles to `anyOf: [<branch>, { "type": "null" }]`, so the real type (and, for the enum, its `enum` list) sits on a branch rather than at the top level. `get_temp` sits alongside it with a plain, non-nullable `units` enum for comparison. Plain streamable-HTTP — connect with the **default (legacy)** protocol era.
Open the Tools tab and select `record_shipment`: `direction` must render as a **Select** (`envio` / `recebimento`) with a clear button that sets it back to `null`, `reference` as a text input, `quantity` as a number input, and `express` as a checkbox. On the broken build every one of them fell through to the raw-JSON textarea, which re-escaped its own contents on each keystroke until the value was unusable ([#1928](https://github.com/modelcontextprotocol/inspector/issues/1928)). The tool echoes the arguments it received, so the result panel shows exactly what was sent.
The **TUI** had the same gap and is worth checking against the same server (`--tui`, then test `record_shipment`): `direction` is a select, `quantity` an integer field, `express` a boolean. Both clients now share one collapse step — `normalizeNullableUnion` in [`core/json/nullableUnion.ts`](./core/json/nullableUnion.ts) — precisely so they cannot drift on which schemas they can render.
#### Advertised extensions
`advertised-extensions-http.json` serves `echo` (always) and a `get_weather` tool **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the client declared that extension in its `capabilities.extensions`.

View file

@ -213,4 +213,173 @@ describe("schemaToForm", () => {
);
expect(form.sections[0]!.fields[0]).toMatchObject({ initialValue: "hi" });
});
// #2015 (the TUI twin of #1928): an argument declared with Zod's `.nullish()`
// has no top-level `type`/`enum` — they sit on the surviving `anyOf` branch —
// so before normalization every one of these degraded to a plain text field.
describe("nullable unions", () => {
it("renders a select for an anyOf string-enum|null argument", () => {
const form = schemaToForm(
{
properties: {
direction: {
anyOf: [
{ type: "string", enum: ["envio", "recebimento"] },
{ type: "null" },
],
},
},
},
"nullableEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
name: "direction",
type: "select",
options: [
{ label: "envio", value: "envio" },
{ label: "recebimento", value: "recebimento" },
],
});
});
it("maps the remaining nullable scalar branches to their typed fields", () => {
const form = schemaToForm(
{
properties: {
reference: { anyOf: [{ type: "string" }, { type: "null" }] },
quantity: {
anyOf: [
{ type: "integer", minimum: 1, maximum: 9 },
{ type: "null" },
],
},
ratio: { anyOf: [{ type: "number" }, { type: "null" }] },
express: { anyOf: [{ type: "boolean" }, { type: "null" }] },
},
},
"nullableScalars",
);
const fields = form.sections[0]!.fields;
expect(fields.map((f) => ({ name: f.name, type: f.type }))).toEqual([
{ name: "reference", type: "string" },
{ name: "quantity", type: "integer" },
{ name: "ratio", type: "float" },
{ name: "express", type: "boolean" },
]);
// The branch's own constraints are hoisted along with its type.
expect(fields[1]).toMatchObject({ min: 1, max: 9 });
});
it("renders a select for a nullable array-of-enum argument", () => {
const form = schemaToForm(
{
properties: {
tags: {
anyOf: [
{ type: "array", items: { enum: ["a", "b"] } },
{ type: "null" },
],
},
},
},
"nullableArrayEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
type: "select",
options: [
{ label: "a", value: "a" },
{ label: "b", value: "b" },
],
});
});
// With the null left in the list, the all-strings check would reject the
// enum and this would degrade to a plain text field.
it("renders a select for a type: [string, null] enum, null stripped", () => {
const form = schemaToForm(
{
properties: {
direction: {
type: ["string", "null"],
enum: ["envio", "recebimento", null],
},
},
},
"typeArrayNullableEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
type: "select",
options: [
{ label: "envio", value: "envio" },
{ label: "recebimento", value: "recebimento" },
],
});
});
it("collapses the type: [T, null] encoding too", () => {
const form = schemaToForm(
{
properties: {
note: { type: ["string", "null"] },
flag: { type: ["boolean", "null"] },
},
},
"typeArrayNull",
);
expect(
form.sections[0]!.fields.map((f) => ({ name: f.name, type: f.type })),
).toEqual([
{ name: "note", type: "string" },
{ name: "flag", type: "boolean" },
]);
});
// ink-form hands a select's stringified value straight back, so a numeric
// enum routed to a select would submit "1" for 1. The typed field loses the
// enum constraint but keeps the value's type — the safer loss.
it("routes a nullable numeric enum to its typed field, not a select", () => {
const form = schemaToForm(
{
properties: {
level: {
anyOf: [{ type: "integer", enum: [1, 2] }, { type: "null" }],
},
ratio: {
anyOf: [{ type: "number", enum: [0.5, 1.5] }, { type: "null" }],
},
},
},
"numericEnum",
);
expect(
form.sections[0]!.fields.map((f) => ({ name: f.name, type: f.type })),
).toEqual([
{ name: "level", type: "integer" },
{ name: "ratio", type: "float" },
]);
});
it("routes a plain numeric enum to its typed field too", () => {
const form = schemaToForm(
{ properties: { level: { type: "integer", enum: [1, 2] } } },
"plainNumericEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({ type: "integer" });
});
it("leaves a union of two real types as a plain string field", () => {
const form = schemaToForm(
{
properties: {
mixed: { anyOf: [{ type: "string" }, { type: "number" }] },
},
},
"realUnion",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
name: "mixed",
type: "string",
});
});
});
});

View file

@ -3,10 +3,19 @@
*/
import type { FormStructure, FormSection, FormField } from "ink-form";
import {
isStringEnum,
normalizeNullableUnion,
} from "@inspector/core/json/nullableUnion.js";
/** Minimal JSON Schema property shape used when building tool parameter forms */
interface JsonSchemaProperty {
type?: string;
/**
* An array here is the `["string", "null"]` nullable encoding, which
* {@link normalizeNullableUnion} collapses to a single name before the
* dispatch below reads it.
*/
type?: string | string[];
title?: string;
enum?: unknown[];
/** Non-standard legacy support: titles for enum values */
@ -15,6 +24,8 @@ interface JsonSchemaProperty {
minimum?: number;
maximum?: number;
default?: unknown;
/** Present on a nullable union; see {@link normalizeNullableUnion}. */
anyOf?: readonly unknown[];
}
/**
@ -68,9 +79,16 @@ export function schemaToForm(
// `properties` values are `unknown` (the SDK schema admits anything), so
// guard before treating a value as a schema object — a malformed server
// schema with e.g. `properties: { foo: null }` must not throw on `.title`.
const property = (
typeof prop === "object" && prop !== null ? prop : {}
) as JsonSchemaProperty;
// Flatten a nullable union (`anyOf: [X, {type:"null"}]`, `type: [X,"null"]`)
// before dispatching. Every branch below reads a single `type` string, and
// for an enum the `enum` keyword sits on the union's surviving branch — so
// without this an argument declared with Zod's `.nullish()` loses its
// select and degrades to a plain text field (#2015, the TUI twin of #1928).
const property = normalizeNullableUnion(
(typeof prop === "object" && prop !== null
? prop
: {}) as JsonSchemaProperty,
);
const baseField = {
name: key,
label: property.title || key,
@ -90,12 +108,17 @@ export function schemaToForm(
...baseField,
options: toSelectOptions(property.items.enum, property.items.enumNames),
} as FormField;
} else if (property.enum) {
// Single select
} else if (isStringEnum(property.enum)) {
// Single select. Gated on the members being strings because
// `toSelectOptions` stringifies them and ink-form hands the string
// straight back: a numeric `enum: [1, 2]` would submit `"1"` and violate
// the schema. A typed non-string enum falls through to its typed field
// below, which loses the enum constraint but keeps the value's type —
// the safer of the two losses.
field = {
type: "select",
...baseField,
options: toSelectOptions(property.enum, property.enumNames),
options: toSelectOptions(property.enum ?? [], property.enumNames),
} as FormField;
} else {
// Map JSON Schema types to ink-form types

View file

@ -2,6 +2,7 @@ import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { InspectorFormSchema } from "../../../utils/jsonUtils";
import { toFormSchema } from "../../../utils/jsonUtils";
import {
fireEvent,
renderWithMantine,
@ -737,7 +738,52 @@ describe("SchemaForm", () => {
expect(lastCall.config).toEqual([1, 2]);
});
it("falls back to passing raw string to onChange when JSON is invalid in JsonInput", async () => {
// The JSON field used to store unparseable text back as the *value*, which
// the next render re-stringified — so each keystroke added a layer of
// escaping (`[` → `"["` → `"\"[\""`). That compounding escape is #1928's
// original symptom, and it lived here rather than in the dispatch. It matters
// more since #2007, whose fix deliberately routes object unions to this
// editor: a fallback nobody can type into is not a fallback.
it("shows an error while the JSON draft does not parse", async () => {
const user = userEvent.setup();
const schema: InspectorFormSchema = {
type: "object",
properties: {
config: { type: "array", title: "Config" },
},
};
function Harness() {
const [values, setValues] = useState<Record<string, unknown>>({});
return (
<SchemaForm schema={schema} values={values} onChange={setValues} />
);
}
renderWithMantine(<Harness />);
const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement;
await user.type(jsonInput, "[[1,");
// Invalid text yields no value, so without this the field would submit as
// absent while the user is still looking at what they typed.
expect(screen.getByText(/Not valid JSON/)).toBeInTheDocument();
await user.type(jsonInput, "2]");
expect(screen.queryByText(/Not valid JSON/)).not.toBeInTheDocument();
});
it("shows no error for an empty optional field", () => {
const schema: InspectorFormSchema = {
type: "object",
properties: {
config: { type: "array", title: "Config" },
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
);
expect(screen.queryByText(/Not valid JSON/)).not.toBeInTheDocument();
});
it("reports no value, not raw text, while the JSON is mid-edit", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const schema: InspectorFormSchema = {
@ -751,9 +797,59 @@ describe("SchemaForm", () => {
);
const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement;
await user.type(jsonInput, "x");
expect(onChange).toHaveBeenCalled();
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
expect(typeof lastCall.config).toBe("string");
expect(onChange).toHaveBeenLastCalledWith({ config: undefined });
});
it("lets an array literal be typed one character at a time", async () => {
const user = userEvent.setup();
const schema: InspectorFormSchema = {
type: "object",
properties: {
config: { type: "array", title: "Config" },
},
};
// Drive the real controlled loop: each onChange feeds straight back in as
// `values`, which is what turned the old handler's raw-text write into a
// compounding re-escape.
function Harness() {
const [values, setValues] = useState<Record<string, unknown>>({});
return (
<SchemaForm schema={schema} values={values} onChange={setValues} />
);
}
renderWithMantine(<Harness />);
const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement;
// `[` is a userEvent keyboard descriptor, so it is escaped as `[[`.
await user.type(jsonInput, '[[1,"a"]');
// The box shows exactly what was typed — no injected quotes or backslashes.
expect(jsonInput.value).toBe('[1,"a"]');
});
it("keeps an in-progress draft visible instead of rewriting it", async () => {
const user = userEvent.setup();
const schema: InspectorFormSchema = {
type: "object",
properties: {
config: { type: "array", title: "Config" },
},
};
function Harness() {
const [values, setValues] = useState<Record<string, unknown>>({});
return (
<SchemaForm schema={schema} values={values} onChange={setValues} />
);
}
renderWithMantine(<Harness />);
const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement;
await user.type(jsonInput, "[[1,");
// Unparseable so far, and it must survive the re-render untouched.
expect(jsonInput.value).toBe("[1,");
await user.type(jsonInput, "2]");
expect(jsonInput.value).toBe("[1,2]");
});
it("uses default values when value is undefined", () => {
@ -811,3 +907,380 @@ describe("SchemaForm", () => {
expect(container.firstChild).not.toBeNull();
});
});
// #1928: "optional AND explicitly nullable" (Zod's `.nullish()`) compiles to a
// nullable union rather than a plain type. Before the normalization step these
// matched no branch and fell through to the raw-JSON fallback, where every
// keystroke re-escaped the value.
describe("SchemaForm nullable unions", () => {
it("renders a Select for an anyOf string-enum|null field", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
properties: {
type: {
title: "Type",
anyOf: [
{ type: "string", enum: ["envio", "recebimento"] },
{ type: "null" },
],
},
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
await user.click(screen.getByRole("textbox", { name: "Type" }));
await user.click(
await screen.findByRole("option", { name: "envio", hidden: true }),
);
expect(onChange).toHaveBeenCalledWith({ type: "envio" });
});
it("clears a nullable enum back to null", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
properties: {
type: {
title: "Type",
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
},
},
};
renderWithMantine(
<SchemaForm
schema={schema}
values={{ type: "envio" }}
onChange={onChange}
/>,
);
// Mantine marks its combobox clear button `aria-hidden` (it is mouse-only,
// `tabIndex={-1}`), so it is only reachable with `hidden: true`.
await user.click(screen.getByRole("button", { hidden: true }));
expect(onChange).toHaveBeenCalledWith({ type: null });
});
it("offers no clear affordance on a non-nullable enum", () => {
const schema: InspectorFormSchema = {
type: "object",
properties: {
format: { type: "string", title: "Format", enum: ["json"] },
},
};
renderWithMantine(
<SchemaForm
schema={schema}
values={{ format: "json" }}
onChange={vi.fn()}
/>,
);
expect(
screen.queryByRole("button", { hidden: true }),
).not.toBeInTheDocument();
});
// The other supported nullable encoding: keywords stay at the top level, so
// the null sentinel sits inside the enum list rather than on a branch.
it("renders a Select for a type: [string, null] enum, without a null option", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
// Built through `toFormSchema`, the same narrowing boundary every
// production call site uses, rather than cast into `InspectorFormSchema`.
// A `null` member is valid JSON Schema but outside that type's `string[]`
// `enum`, and this fixture *is* wire data — so the honest way to introduce
// it is the wire→form narrow, not a double cast that erases the mismatch.
const schema = toFormSchema({
type: "object",
properties: {
direction: {
title: "Direction",
type: ["string", "null"],
enum: ["envio", "recebimento", null],
},
},
});
renderWithMantine(
<SchemaForm schema={schema!} values={{}} onChange={onChange} />,
);
await user.click(screen.getByRole("textbox", { name: "Direction" }));
const options = await screen.findAllByRole("option", { hidden: true });
expect(options.map((option) => option.textContent)).toEqual([
"envio",
"recebimento",
]);
await user.click(options[0]);
expect(onChange).toHaveBeenCalledWith({ direction: "envio" });
});
it("renders a TextInput for a type: [string, null] field", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
properties: {
note: { type: ["string", "null"], title: "Note" },
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
await user.type(screen.getByLabelText(/Note/), "a");
expect(onChange).toHaveBeenCalledWith({ note: "a" });
});
it("renders a checkbox for an anyOf boolean|null field", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
properties: {
enabled: {
title: "Enabled",
anyOf: [{ type: "boolean" }, { type: "null" }],
},
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
await user.click(screen.getByLabelText("Enabled"));
expect(onChange).toHaveBeenCalledWith({ enabled: true });
});
it("renders a MultiSelect for an anyOf array-of-enum|null field", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
properties: {
tags: {
title: "Tags",
anyOf: [
{ type: "array", items: { type: "string", enum: ["a", "b"] } },
{ type: "null" },
],
},
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
await user.click(screen.getByRole("textbox", { name: "Tags" }));
await user.click(
await screen.findByRole("option", { name: "a", hidden: true }),
);
expect(onChange).toHaveBeenCalledWith({ tags: ["a"] });
});
it("renders nested fields for an anyOf object|null field", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
properties: {
profile: {
title: "Profile",
anyOf: [
{
type: "object",
properties: { nick: { type: "string", title: "Nick" } },
},
{ type: "null" },
],
},
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
await user.type(screen.getByLabelText(/Nick/), "z");
expect(onChange).toHaveBeenCalledWith({ profile: { nick: "z" } });
});
// #2007: `z.array(z.union([z.object(…), z.object(…)]))` gives an `items.anyOf`
// whose branches carry no top-level `const`, so every MultiSelect option was
// the empty string — and Mantine *throws* on duplicate option values, greying
// out the whole tool panel. The nullable form below is reachable only because
// this PR now collapses it into `type: "array"`, so it has to be safe too.
it("falls back to the JSON input for an array of object-union items", () => {
const objectBranch = (name: string): InspectorFormSchema => ({
type: "object",
properties: { type: { type: "string", const: name } },
required: ["type"],
});
const schema: InspectorFormSchema = {
type: "object",
properties: {
items: {
title: "Items",
type: "array",
items: { anyOf: [objectBranch("A"), objectBranch("B")] },
},
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
);
expect(screen.getByLabelText(/Items/).tagName).toBe("TEXTAREA");
});
it("falls back to the JSON input for a nullable array of object-union items", () => {
const objectBranch = (name: string): InspectorFormSchema => ({
type: "object",
properties: { type: { type: "string", const: name } },
required: ["type"],
});
const schema: InspectorFormSchema = {
type: "object",
properties: {
items: {
title: "Items",
anyOf: [
{ type: "array", items: { anyOf: [objectBranch("A")] } },
{ type: "null" },
],
},
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
);
expect(screen.getByLabelText(/Items/).tagName).toBe("TEXTAREA");
});
// Mantine's select is string-valued, so a numeric const would submit "1" for
// 1 — the same wrong-type-on-the-wire problem that keeps a numeric enum off
// the select path.
it("falls back to the JSON input for non-string anyOf consts", () => {
const schema: InspectorFormSchema = {
type: "object",
properties: {
items: {
title: "Items",
type: "array",
items: { anyOf: [{ const: 1 }, { const: 2 }] },
},
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
);
expect(screen.getByLabelText(/Items/).tagName).toBe("TEXTAREA");
});
it("falls back to the JSON input when two anyOf branches share a const", () => {
const schema: InspectorFormSchema = {
type: "object",
properties: {
items: {
title: "Items",
type: "array",
items: { anyOf: [{ const: "dup" }, { const: "dup" }] },
},
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
);
expect(screen.getByLabelText(/Items/).tagName).toBe("TEXTAREA");
});
it("falls back to a text input for a string oneOf with no consts", () => {
const schema: InspectorFormSchema = {
type: "object",
properties: {
mode: {
title: "Mode",
type: "string",
oneOf: [{ type: "string" }, { type: "string" }],
},
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
);
expect(screen.getByLabelText(/Mode/).tagName).toBe("INPUT");
});
it("still renders a MultiSelect when every anyOf branch has a distinct const", () => {
const schema: InspectorFormSchema = {
type: "object",
properties: {
items: {
title: "Items",
type: "array",
items: { anyOf: [{ const: "a", title: "Alpha" }, { const: "b" }] },
},
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
);
expect(screen.getByRole("textbox", { name: "Items" })).toBeInTheDocument();
});
// Reachable only since the collapse landed: the nullable wrapper had no
// top-level `type` before, so it fell to the JSON editor rather than into a
// string-valued MultiSelect that would submit ["1"] for [1].
it("falls back to the JSON input for a nullable array of numeric item enums", () => {
// Wire data again — a numeric `enum` is valid JSON Schema but outside
// `InspectorFormSchema`'s `string[]`, so it comes in through the narrow.
const schema = toFormSchema({
type: "object",
properties: {
levels: {
title: "Levels",
anyOf: [{ type: "array", items: { enum: [1, 2] } }, { type: "null" }],
},
},
});
renderWithMantine(
<SchemaForm schema={schema!} values={{}} onChange={vi.fn()} />,
);
expect(screen.getByLabelText(/Levels/).tagName).toBe("TEXTAREA");
});
// The sibling enum rules null out even though the type list names it, so the
// field must not get a clear button that emits a value the schema rejects.
it("offers no clear button when a sibling enum excludes null", () => {
const schema = toFormSchema({
type: "object",
properties: {
direction: {
title: "Direction",
type: ["string", "null"],
enum: ["envio", "recebimento"],
},
},
});
renderWithMantine(
<SchemaForm
schema={schema!}
values={{ direction: "envio" }}
onChange={vi.fn()}
/>,
);
expect(
screen.queryByRole("button", { hidden: true }),
).not.toBeInTheDocument();
});
it("still falls back to the JSON input for a union of two real types", () => {
const schema: InspectorFormSchema = {
type: "object",
properties: {
mixed: {
title: "Mixed",
anyOf: [{ type: "string" }, { type: "number" }],
},
},
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
);
expect(screen.getByLabelText(/Mixed/).tagName).toBe("TEXTAREA");
});
});

View file

@ -11,7 +11,14 @@ import {
import { useState } from "react";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import { useValueChange } from "../../../hooks/useValueChange";
import type { InspectorFormSchema } from "../../../utils/jsonUtils";
import type {
InspectorFormSchema,
JsonSchemaConst,
} from "../../../utils/jsonUtils";
import {
isStringEnum,
normalizeNullableUnion,
} from "@inspector/core/json/nullableUnion.js";
const FieldLabel = Text.withProps({
fw: 500,
@ -51,6 +58,58 @@ function toEnumData(
return values;
}
/**
* Build `Select`/`MultiSelect` option data from a list of `oneOf`/`anyOf`
* branches that are expected to be constants.
*
* Returns `null` when the branches are not usable as options, which sends the
* field to the JSON fallback instead. Four ways that happens:
*
* - **A non-string `const`.** Mantine's select is string-valued, so
* `anyOf: [{ const: 1 }, { const: 2 }]` would submit `["1"]` where the
* schema says `[1]` — the same wrong-type-on-the-wire problem that keeps a
* numeric `enum` off the select path. An inspector must not misreport what
* it sends, so this stays on the JSON editor, where the value keeps its type.
* - **No `const` at all.** An `anyOf` of *object* schemas — what
* `z.array(z.union([z.object(…), z.object(…)]))` compiles to — has no
* top-level `const` on any branch, so every option would be the empty
* string. Mantine **throws** on duplicate option values, which greys out the
* whole tool panel rather than degrading (#2007).
* - **Duplicate values.** Two branches sharing a `const` throw the same way.
* - **An empty option value**, which Mantine cannot render as selectable.
*
* A union of object shapes has no faithful dropdown anyway, so the JSON editor
* is the honest widget for it rather than a lossy or crashing one.
*/
function toConstOptions(
branches: (InspectorFormSchema | JsonSchemaConst)[],
): { value: string; label: string }[] | null {
const options: { value: string; label: string }[] = [];
const seen = new Set<string>();
for (const branch of branches) {
if (typeof branch.const !== "string") {
return null;
}
const value = branch.const;
if (value === "" || seen.has(value)) {
return null;
}
seen.add(value);
options.push({ value, label: branch.title ?? value });
}
return options.length > 0 ? options : null;
}
/**
* Whether an enum `Select` should offer a clear affordance. Only a schema that
* admits `null` does: clearing emits `null`, and an enum without a null branch
* would reject it. Without this a nullable enum is a one-way door — once a
* value is picked there is no way back to "no answer".
*/
function isClearable(fieldSchema: InspectorFormSchema): boolean {
return fieldSchema.nullable === true;
}
/**
* Interpret whatever Mantine's `NumberInput` reported as the JSON value for the
* field. Anything that is not a finite number becomes `undefined`, which is how
@ -90,6 +149,110 @@ function toNumericValue(raw: string | number): number | undefined {
return Number.isSafeInteger(Math.trunc(parsed)) ? parsed : undefined;
}
/** Parse editor text, reporting `undefined` for anything that is not JSON yet. */
function parseJsonDraft(text: string): unknown {
if (text.trim() === "") {
return undefined;
}
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
/** Structural equality for the draft/value re-sync, via canonical JSON. */
function isSameJson(a: unknown, b: unknown): boolean {
if (a === undefined || b === undefined) {
return a === b;
}
return serializeJson(a) === serializeJson(b);
}
interface SchemaJsonFieldProps {
label: string;
description?: string;
withAsterisk: boolean;
disabled: boolean;
value: unknown;
onChange: (value: unknown) => void;
}
/**
* A `JsonInput` that keeps the text the user is typing, not just the value it
* currently parses to.
*
* This field used to drive the box straight off the parent's value and, when
* the text failed `JSON.parse`, store the **raw text** back as the value — which
* the next render re-`JSON.stringify`d, adding a layer of escaping per
* keystroke. Typing `[` gave `"["`, then `"\"[\""`, and the field was unusable
* within a few characters. That compounding escape is the original #1928
* symptom, and it lived in this fallback rather than in the dispatch.
*
* Routing nullable fields to real widgets removed the common way of *landing*
* here, but it did not fix the editor — and #2007's fix deliberately sends a
* union of object shapes to it, so the editor itself has to be typeable. Hence
* the same split `SchemaNumberInput` uses: the raw text is the source of truth
* for what is *displayed*, while the parent only ever sees parsed JSON.
*
* While the text is mid-edit and does not parse, the parent is told
* `undefined` rather than handed the text. An inspector must not report a value
* it cannot send, and `undefined` is how "no value supplied" is spelled
* everywhere else in this form.
*
* That leaves invalid text and an empty field indistinguishable *to the
* parent*, so the field says so itself via `error`. A **required** field is
* already gated — its value is `undefined`, which `hasMissingRequiredFields`
* treats as missing — but an **optional** one would otherwise be dropped from
* the submission with no indication. Disabling Execute/Open/Submit on an
* invalid optional draft needs a validity channel out of this component and
* into all four callers, which is a wider change than this fix; tracked
* separately.
*/
function SchemaJsonField({
value,
onChange,
...inputProps
}: SchemaJsonFieldProps) {
const [draft, setDraft] = useState(() =>
value === undefined ? "" : serializeJson(value),
);
// Re-sync only when the parent's value genuinely diverges from what the draft
// parses to, which leaves an external reset (a cleared form, a loaded
// example) working while an in-progress `[` — whose parse is `undefined`,
// matching the `undefined` we just emitted — is left alone.
useValueChange(value, (next) => {
if (!isSameJson(parseJsonDraft(draft), next)) {
setDraft(next === undefined ? "" : serializeJson(next));
}
});
// Text that is present but does not parse yields no value, so the field would
// otherwise submit as absent while the user is still looking at what they
// typed. Saying so on the field is what keeps that from being silent; see the
// note on `SchemaJsonField` for the submit-gating half, which is not local to
// this component.
const hasInvalidDraft =
draft.trim() !== "" && parseJsonDraft(draft) === undefined;
return (
<SchemaJsonInput
{...inputProps}
value={draft}
error={
hasInvalidDraft
? "Not valid JSON — this field will be omitted"
: undefined
}
onChange={(text) => {
setDraft(text);
onChange(parseJsonDraft(text));
}}
/>
);
}
interface SchemaNumberInputProps {
label: string;
description?: string;
@ -207,7 +370,13 @@ export function SchemaForm({
onChange({ ...values, [fieldName]: fieldValue });
}
function renderField(fieldName: string, fieldSchema: InspectorFormSchema) {
function renderField(fieldName: string, rawSchema: InspectorFormSchema) {
// Flatten a nullable union (`anyOf: [X, {type:"null"}]`, `type: [X,"null"]`)
// before dispatching. Every branch below tests a single `type` string, so
// 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 = normalizeNullableUnion(rawSchema);
const isRequired = requiredFields.includes(fieldName);
const label = fieldSchema.title ?? fieldName;
const description = fieldSchema.description;
@ -223,6 +392,7 @@ export function SchemaForm({
withAsterisk={isRequired}
disabled={disabled}
data={toEnumData(fieldSchema.enum, fieldSchema.enumNames)}
clearable={isClearable(fieldSchema)}
value={(rawValue as string) ?? null}
onChange={(val) => handleFieldChange(fieldName, val)}
/>
@ -230,11 +400,11 @@ export function SchemaForm({
}
// string with oneOf
if (fieldSchema.type === "string" && fieldSchema.oneOf) {
const data = fieldSchema.oneOf.map((item) => ({
value: String(item.const ?? ""),
label: item.title ?? String(item.const ?? ""),
}));
const oneOfData = fieldSchema.oneOf
? toConstOptions(fieldSchema.oneOf)
: null;
if (fieldSchema.type === "string" && oneOfData) {
const data = oneOfData;
return (
<Select
key={fieldName}
@ -243,6 +413,7 @@ export function SchemaForm({
withAsterisk={isRequired}
disabled={disabled}
data={data}
clearable={isClearable(fieldSchema)}
value={(rawValue as string) ?? null}
onChange={(val) => handleFieldChange(fieldName, val)}
/>
@ -312,12 +483,14 @@ export function SchemaForm({
);
}
// array of enum values (multi-select)
if (fieldSchema.type === "array" && fieldSchema.items?.enum) {
const data = toEnumData(
fieldSchema.items.enum,
fieldSchema.items.enumNames,
);
// array of enum values (multi-select). Gated on the members being strings
// for the same reason `toConstOptions` is: `MultiSelect` is string-valued,
// so `items: { enum: [1, 2] }` would submit `["1"]` where the schema says
// `[1]`. Reachable from a nullable array since the collapse landed, which
// is what makes the guard load-bearing rather than theoretical.
const itemsEnum = fieldSchema.items?.enum;
if (fieldSchema.type === "array" && isStringEnum(itemsEnum)) {
const data = toEnumData(itemsEnum, fieldSchema.items?.enumNames);
return (
<MultiSelect
key={fieldName}
@ -333,11 +506,11 @@ export function SchemaForm({
}
// array with items having anyOf
if (fieldSchema.type === "array" && fieldSchema.items?.anyOf) {
const data = fieldSchema.items.anyOf.map((item) => ({
value: String(item.const ?? ""),
label: item.title ?? String(item.const ?? ""),
}));
const itemsAnyOfData = fieldSchema.items?.anyOf
? toConstOptions(fieldSchema.items.anyOf)
: null;
if (fieldSchema.type === "array" && itemsAnyOfData) {
const data = itemsAnyOfData;
return (
<MultiSelect
key={fieldName}
@ -376,20 +549,16 @@ export function SchemaForm({
// fallback: JsonInput for complex schemas
return (
<SchemaJsonInput
key={fieldName}
<SchemaJsonField
// Holds local draft state, so — like the number field — it has to be
// remounted when `resetKey` says the form moved to another entity.
key={resetKey === undefined ? fieldName : `${resetKey}:${fieldName}`}
label={label}
description={description}
withAsterisk={isRequired}
disabled={disabled}
value={rawValue !== undefined ? serializeJson(rawValue) : ""}
onChange={(val) => {
try {
handleFieldChange(fieldName, JSON.parse(val));
} catch {
handleFieldChange(fieldName, val);
}
}}
value={rawValue}
onChange={(val) => handleFieldChange(fieldName, val)}
/>
);
}

View file

@ -0,0 +1,734 @@
import { describe, it, expect } from "vitest";
import {
admitsNull,
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,
});
});
// JSON Schema's `enum` is untyped, so a typeless branch only implies strings
// when every member is one. Guessing otherwise would hand a number to a
// renderer that declared the option list `string[]`.
it("does not infer string for a typeless non-string enum branch", () => {
const schema = { anyOf: [{ enum: [1, 2] }, { type: "null" as const }] };
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("does not infer string for a typeless mixed enum branch", () => {
const schema = { anyOf: [{ enum: ["a", 2] }, { type: "null" as const }] };
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("does not infer string for a typeless empty enum branch", () => {
const schema = { anyOf: [{ enum: [] }, { type: "null" as const }] };
expect(normalizeNullableUnion(schema)).toBe(schema);
});
// An explicit `type` is authoritative — the enum members are the server's
// problem at that point, not an inference this function is making.
it("still collapses a non-string enum branch that declares its type", () => {
expect(
normalizeNullableUnion({
anyOf: [{ type: "number", enum: [1, 2] }, { type: "null" }],
}),
).toEqual({
type: "number",
enum: [1, 2],
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,
});
});
// The `type: [T, "null"]` encoding keeps its keywords at the top level, so a
// nullable enum written that way carries the null *inside* the list. Leaving
// it there hands `null` to Mantine as option data, and makes the TUI's
// all-strings check reject the enum and fall back to a text field.
it("strips the null sentinel from a type-array nullable enum", () => {
expect(
normalizeNullableUnion({
type: ["string", "null"],
enum: ["envio", "recebimento", null],
}),
).toEqual({
type: "string",
enum: ["envio", "recebimento"],
anyOf: undefined,
nullable: true,
});
});
it("strips a null sentinel hoisted off an anyOf branch too", () => {
expect(
normalizeNullableUnion({
anyOf: [{ type: "string", enum: ["a", null] }, { type: "null" }],
}),
).toEqual({
type: "string",
enum: ["a"],
anyOf: undefined,
nullable: true,
});
});
// Declining is the point: emitting `enum: undefined` would turn a schema that
// permits *only* `null` into a plain string field accepting arbitrary text,
// inviting values the schema forbids. Left uncollapsed it renders through the
// JSON editor, which represents it honestly.
it("declines to collapse an enum that held nothing but null", () => {
const schema = { type: ["string", "null"], enum: [null] };
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("declines the same way on the anyOf encoding", () => {
const schema = {
anyOf: [{ type: "string", enum: [null] }, { type: "null" as const }],
};
expect(normalizeNullableUnion(schema)).toBe(schema);
});
// The parallel `enumNames` is filtered by the same indices. Both renderers
// discard labels outright on a length mismatch, so stripping one without the
// other would silently lose every label, not just the dropped one's.
it("keeps enumNames aligned when stripping the null member", () => {
expect(
normalizeNullableUnion({
type: ["string", "null"],
enum: ["a", null, "b"],
enumNames: ["Alpha", "None", "Beta"],
}),
).toEqual({
type: "string",
enum: ["a", "b"],
enumNames: ["Alpha", "Beta"],
anyOf: undefined,
nullable: true,
});
});
// Flattening still happens — only the nullability claim is withheld, because
// the sibling enum rules null out even though the type list names it.
it("flattens but does not mark nullable when a sibling enum excludes null", () => {
expect(
normalizeNullableUnion({
type: ["string", "null"],
enum: ["envio", "recebimento"],
}),
).toEqual({
type: "string",
enum: ["envio", "recebimento"],
anyOf: undefined,
nullable: false,
});
});
it("still marks nullable when the enum lives on an anyOf branch", () => {
expect(
normalizeNullableUnion({
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
}),
).toEqual({
type: "string",
enum: ["envio"],
anyOf: undefined,
nullable: true,
});
});
// Keeping a mismatched list is not neutral: filtering shortens `enum`, so a
// list both renderers had correctly *ignored* can come out the same length
// and start labelling — `enum: [null, "b"]` + `enumNames: ["None"]` would
// otherwise label `b` as "None". Dropping it preserves the ignore.
it("drops a mismatched enumNames rather than letting filtering align it", () => {
expect(
normalizeNullableUnion({
type: ["string", "null"],
enum: [null, "b"],
enumNames: ["None"],
}),
).toEqual({
type: "string",
enum: ["b"],
enumNames: undefined,
anyOf: undefined,
nullable: true,
});
});
it("drops a mismatched enumNames that stays mismatched too", () => {
expect(
normalizeNullableUnion({
type: ["string", "null"],
enum: ["a", null],
enumNames: ["Alpha"],
}),
).toEqual({
type: "string",
enum: ["a"],
enumNames: undefined,
anyOf: undefined,
nullable: true,
});
});
// An empty enum permits nothing at all, so it reaches the same conclusion as
// `[null]`: no value a dropdown could offer, so do not build one.
it("declines to collapse an empty enum", () => {
const typeArray = { type: ["string", "null"], enum: [] };
expect(normalizeNullableUnion(typeArray)).toBe(typeArray);
const union = {
anyOf: [{ type: "string", enum: [] }, { type: "null" as const }],
};
expect(normalizeNullableUnion(union)).toBe(union);
});
// `$ref` applies alongside its siblings and is never resolved here, so a
// branch carrying one can be unsatisfiable.
it("treats $ref as opaque wherever it appears", () => {
const onBranch = {
anyOf: [
{ type: "string", $ref: "#/$defs/intOnly" },
{ type: "null" as const },
],
};
expect(normalizeNullableUnion(onBranch)).toBe(onBranch);
const onTypeArray = { type: ["string", "null"], $ref: "#/$defs/intOnly" };
expect(normalizeNullableUnion(onTypeArray)).toBe(onTypeArray);
expect(admitsNull({ type: ["string", "null"], $ref: "#/$defs/x" })).toBe(
false,
);
expect(admitsNull({ anyOf: [{ type: "null", $ref: "#/$defs/x" }] })).toBe(
false,
);
});
// The type-array path keeps the schema's own keywords, but `collapsed` clears
// `anyOf` and the renderers ignore the other applicators — so a compound
// schema would be widened into an unconstrained field rather than collapsed.
it("declines a type-array nullable that also carries a sibling anyOf", () => {
const schema = {
type: ["string", "null"],
anyOf: [{ const: "a" }],
};
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("declines a type-array nullable that carries an opaque applicator", () => {
const withNot = { type: ["string", "null"], not: { const: "a" } };
expect(normalizeNullableUnion(withNot)).toBe(withNot);
const withOneOf = { type: ["string", "null"], oneOf: [{ const: "a" }] };
expect(normalizeNullableUnion(withOneOf)).toBe(withOneOf);
const withAllOf = { type: ["string", "null"], allOf: [{ minLength: 2 }] };
expect(normalizeNullableUnion(withAllOf)).toBe(withAllOf);
});
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);
});
// The hoist is a spread, but JSON Schema applies sibling keywords
// conjunctively — so a branch keyword replacing the wrapper's would *widen*
// the field. `enum: ["a"]` around a branch `enum: ["a","b"]` means "a", yet
// the spread would offer "b" in the dropdown and submit it.
it("declines when the branch would drop a conflicting wrapper enum", () => {
const schema = {
enum: ["a"],
anyOf: [{ type: "string", enum: ["a", "b"] }, { type: "null" as const }],
};
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("declines when wrapper and branch disagree on type", () => {
const schema = {
type: "string",
anyOf: [{ type: "integer" }, { type: "null" as const }],
};
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("declines when wrapper and branch disagree on bounds", () => {
const schema = {
minimum: 5,
anyOf: [{ type: "integer", minimum: 1 }, { type: "null" as const }],
};
expect(normalizeNullableUnion(schema)).toBe(schema);
});
// Deliberately stricter than value-equality: a wrapper restating its branch's
// `type` also declines. That costs a dropdown on a rare redundant shape and
// buys a rule with no gap, which is the better trade for a module that cannot
// evaluate JSON Schema.
it("declines even when wrapper and branch agree on a shared keyword", () => {
const schema = {
type: "string",
anyOf: [{ type: "string", enum: ["a"] }, { type: "null" as const }],
};
expect(normalizeNullableUnion(schema)).toBe(schema);
});
// The hoist *drops* a nested union: the branch's `anyOf` overwrites the
// wrapper's in the spread and is then cleared, so the constraint vanishes and
// the field widens into an unconstrained nullable string.
it("declines when the surviving branch carries its own anyOf", () => {
const schema = {
anyOf: [
{ type: "string", anyOf: [{ const: "a" }] },
{ type: "null" as const },
],
};
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("declines when the surviving branch carries an opaque applicator", () => {
const withNot = {
anyOf: [
{ type: "string", not: { const: "a" } },
{ type: "null" as const },
],
};
expect(normalizeNullableUnion(withNot)).toBe(withNot);
const withAllOf = {
anyOf: [
{ type: "string", allOf: [{ minLength: 2 }] },
{ type: "null" as const },
],
};
expect(normalizeNullableUnion(withAllOf)).toBe(withAllOf);
const withOneOf = {
anyOf: [
{ type: "string", oneOf: [{ const: "a" }] },
{ type: "null" as const },
],
};
expect(normalizeNullableUnion(withOneOf)).toBe(withOneOf);
});
it("declines when the wrapper carries an applicator the module cannot read", () => {
const schema = {
allOf: [{ minLength: 2 }],
anyOf: [{ type: "string" }, { type: "null" as const }],
};
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("treats wrapper metadata as safe to keep alongside branch constraints", () => {
expect(
normalizeNullableUnion({
title: "Direction",
description: "Which way",
default: "envio",
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
}),
).toEqual({
title: "Direction",
description: "Which way",
default: "envio",
type: "string",
enum: ["envio"],
anyOf: undefined,
nullable: true,
});
});
it("ignores a schema with no union keywords at all", () => {
const schema = { type: "object" as const, properties: {} };
expect(normalizeNullableUnion(schema)).toBe(schema);
});
});
// `admitsNull` answers a *validity* question and is deliberately decoupled from
// the collapse, which answers a narrower *rendering* one. A schema can admit
// null while being unrenderable as a single widget, and a form that conflated
// the two would reject a value its own schema accepts.
describe("admitsNull", () => {
it("recognizes every encoding the collapse handles", () => {
expect(admitsNull({ anyOf: [{ type: "string" }, { type: "null" }] })).toBe(
true,
);
expect(admitsNull({ type: ["string", "null"] })).toBe(true);
expect(admitsNull({ type: "null" })).toBe(true);
expect(admitsNull({ nullable: true })).toBe(true);
});
it("recognizes a null branch the collapse declines to flatten", () => {
// Three members, so `normalizeNullableUnion` leaves this alone and it
// renders through the JSON fallback — where a user can still enter `null`.
const wide = {
anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }],
};
expect(normalizeNullableUnion(wide)).toBe(wide);
expect(admitsNull(wide)).toBe(true);
});
it("recognizes a null branch declared as a nested type array", () => {
expect(admitsNull({ anyOf: [{ type: ["string", "null"] }] })).toBe(true);
});
// Applicators this module does not evaluate. `oneOf` requires *exactly one*
// branch to match, so a null branch does not by itself mean null validates;
// `not` and `allOf` can rule it out outright. Under-claiming costs a clear
// button, over-claiming lets the form emit a value the schema rejects.
it("declines to claim nullability for applicators it cannot evaluate", () => {
expect(admitsNull({ oneOf: [{ type: "string" }, { type: "null" }] })).toBe(
false,
);
expect(
admitsNull({ not: { type: "null" }, anyOf: [{ type: "null" }] }),
).toBe(false);
expect(
admitsNull({ allOf: [{ type: "string" }], anyOf: [{ type: "null" }] }),
).toBe(false);
});
// A branch can name null and still admit nothing.
it("ignores an unsatisfiable null branch", () => {
expect(
admitsNull({ anyOf: [{ type: "string" }, { type: "null", const: "x" }] }),
).toBe(false);
expect(
admitsNull({
anyOf: [{ type: "string" }, { type: "null", enum: ["x"] }],
}),
).toBe(false);
expect(
admitsNull({
anyOf: [{ type: "string" }, { type: "null", not: { type: "null" } }],
}),
).toBe(false);
});
it("ignores a null branch made unsatisfiable by its own nested anyOf", () => {
expect(
admitsNull({
anyOf: [
{ type: "string" },
{ type: "null", anyOf: [{ type: "string" }] },
],
}),
).toBe(false);
});
it("still accepts a plain null branch alongside an unsatisfiable one", () => {
expect(
admitsNull({
anyOf: [{ type: "null", const: "x" }, { type: "null" }],
}),
).toBe(true);
});
it("is false for a schema that does not permit null", () => {
expect(admitsNull({ type: "string" })).toBe(false);
expect(admitsNull({ type: ["string", "number"] })).toBe(false);
expect(
admitsNull({ anyOf: [{ type: "string" }, { type: "number" }] }),
).toBe(false);
expect(admitsNull({})).toBe(false);
});
it("ignores non-object anyOf members instead of throwing", () => {
expect(admitsNull({ anyOf: ["null", ["null"], 7] })).toBe(false);
});
// JSON Schema keywords at one level are conjunctive, so a syntactic null does
// not by itself mean the schema accepts null. `{ type: ["string","null"],
// enum: ["envio"] }` names "null" and still rejects it.
it("is false when a sibling enum excludes null", () => {
expect(admitsNull({ type: ["string", "null"], enum: ["envio"] })).toBe(
false,
);
expect(
admitsNull({
anyOf: [{ type: "string" }, { type: "null" }],
enum: ["a"],
}),
).toBe(false);
});
it("is true when a sibling enum offers null", () => {
expect(
admitsNull({ type: ["string", "null"], enum: ["envio", null] }),
).toBe(true);
});
it("is false when a sibling const pins a non-null value", () => {
expect(admitsNull({ type: ["string", "null"], const: "envio" })).toBe(
false,
);
expect(admitsNull({ type: ["string", "null"], const: null })).toBe(true);
});
// A branch's enum is scoped to that branch, not a sibling of the union — this
// is the #1928 shape, and reading it as a sibling would call it non-nullable.
// An explicit `type` is conjunctive with the union, so it decides rather than
// merely offering another way to say yes. A `{ type: "null" }` branch cannot
// override a top-level `type: "string"`.
it("lets an explicit non-null type override a null branch", () => {
expect(
admitsNull({
type: "string",
anyOf: [{ type: "string", enum: ["a"] }, { type: "null" }],
}),
).toBe(false);
});
it("still honors an explicit type that does admit null", () => {
expect(admitsNull({ type: ["string", "null"] })).toBe(true);
expect(admitsNull({ type: "null" })).toBe(true);
});
// A sibling union is conjunctive with the `type`, so it can reject null even
// when the type list names it. It is not evaluated here, so its mere presence
// withholds the claim.
it("withholds the claim when a sibling anyOf sits beside an explicit type", () => {
expect(
admitsNull({ type: ["string", "null"], anyOf: [{ type: "string" }] }),
).toBe(false);
// Unsatisfiable: names null, admits nothing.
expect(admitsNull({ type: "null", anyOf: [{ type: "string" }] })).toBe(
false,
);
});
it("is unaffected by an enum that lives inside an anyOf branch", () => {
expect(
admitsNull({
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
}),
).toBe(true);
});
});

View file

@ -0,0 +1,148 @@
import { describe, it, expect, afterEach } from "vitest";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js";
import { toFormSchema } from "../../../utils/jsonUtils";
import type { InspectorFormSchema } from "../../../utils/jsonUtils";
import {
createTestServerHttp,
type TestServerHttp,
createTestServerInfo,
loadConfig,
resolveConfig,
} from "@modelcontextprotocol/inspector-test-server";
/**
* Live coverage of `test-servers/configs/nullable-fields-http.json` — the
* documented manual reproduction for #1928.
*
* The unit tests on both form builders construct the `anyOf` shape by hand,
* which verifies the *renderers* but assumes the premise: that Zod's
* `.nullish()` actually emits `anyOf: [<branch>, { type: "null" }]` with the
* `enum` on the branch. That premise is the whole reason the fix exists, and
* nothing else pins it — a Zod change in how it compiles a nullish enum would
* leave every unit test green while the showcase server quietly stopped
* reproducing the bug.
*
* So the server is built by **resolving the checked-in config**, not by calling
* the fixture factory directly. That is deliberate and is the difference
* between covering the wiring and merely asserting the factory: going through
* `loadConfig` → `resolveConfig` means a misspelt preset name in
* `preset-registry.ts`, or a config that names a preset that no longer exists,
* fails here rather than only when someone runs the repro by hand.
*/
describe("nullable argument schemas over the wire (#1928)", () => {
let client: InspectorClient | null = null;
let server: TestServerHttp | null = null;
const configPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../../test-servers/configs/nullable-fields-http.json",
);
afterEach(async () => {
if (client) {
try {
await client.disconnect();
} catch {
// ignore
}
client = null;
}
if (server) {
try {
await server.stop();
} catch {
// ignore
}
server = null;
}
});
/**
* Boot the showcase config. The harness picks the port rather than using the
* config's fixed one, so this cannot collide with a showcase server someone
* is running by hand.
*/
async function connectToShowcase(): Promise<InspectorClient> {
const resolved = resolveConfig(loadConfig(configPath));
const started = createTestServerHttp({
serverInfo: createTestServerInfo("nullable-fields-test", "1.0.0"),
tools: resolved.tools,
});
await started.start();
server = started;
const connected = new InspectorClient(
{ type: "streamable-http", url: started.url },
{ environment: { transport: createTransportNode } },
);
await connected.connect();
client = connected;
return connected;
}
async function recordShipmentSchema(): Promise<InspectorFormSchema> {
const connected = await connectToShowcase();
const { tools } = await connected.listAllTools();
const tool = tools.find((entry) => entry.name === "record_shipment");
expect(tool).toBeDefined();
const schema = toFormSchema(tool?.inputSchema);
expect(schema).not.toBeNull();
return schema as InspectorFormSchema;
}
it("resolves the preset the config names", () => {
// Fails on a misspelt registry case or a config naming a dead preset —
// neither of which the hand-built unit schemas can see.
const resolved = resolveConfig(loadConfig(configPath));
expect(resolved.tools?.map((tool) => tool.name)).toEqual([
"record_shipment",
"get_temp",
]);
});
it("emits an anyOf-with-null for every nullish argument, enum on the branch", async () => {
const schema = await recordShipmentSchema();
const properties = schema.properties ?? {};
expect(Object.keys(properties).sort()).toEqual([
"direction",
"express",
"quantity",
"reference",
]);
// The premise of the whole fix: no top-level `type`, and the `enum` sits on
// the surviving branch rather than beside it.
const direction = properties.direction;
expect(direction?.type).toBeUndefined();
expect(direction?.enum).toBeUndefined();
expect(direction?.anyOf).toHaveLength(2);
expect(direction?.anyOf).toEqual(
expect.arrayContaining([expect.objectContaining({ type: "null" })]),
);
});
it("collapses each argument to the type its widget dispatches on", async () => {
const schema = await recordShipmentSchema();
const collapsed = Object.fromEntries(
Object.entries(schema.properties ?? {}).map(([name, propertySchema]) => [
name,
normalizeNullableUnion(propertySchema),
]),
);
expect(collapsed.direction?.type).toBe("string");
expect(collapsed.direction?.enum).toEqual(["envio", "recebimento"]);
expect(collapsed.reference?.type).toBe("string");
expect(collapsed.quantity?.type).toBe("integer");
expect(collapsed.express?.type).toBe("boolean");
for (const propertySchema of Object.values(collapsed)) {
expect(propertySchema?.nullable).toBe(true);
expect(propertySchema?.anyOf).toBeUndefined();
}
});
});

View file

@ -221,6 +221,42 @@ describe("collectSchemaDefaults", () => {
it("returns an empty object when the schema has no properties", () => {
expect(collectSchemaDefaults({ type: "object" })).toEqual({});
});
// The form normalizes a nullable union before rendering, so this has to as
// well: a nested object's `properties` live on the union's surviving branch,
// and a default only visible there would be displayed but never submitted.
it("collects defaults hoisted out of a nullable object union", () => {
const schema: InspectorFormSchema = {
type: "object",
properties: {
profile: {
anyOf: [
{
type: "object",
properties: { nick: { type: "string", default: "ada" } },
},
{ type: "null" },
],
},
},
};
expect(collectSchemaDefaults(schema)).toEqual({
profile: { nick: "ada" },
});
});
it("keeps a default declared on the union wrapper itself", () => {
const schema: InspectorFormSchema = {
type: "object",
properties: {
direction: {
default: "envio",
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
},
},
};
expect(collectSchemaDefaults(schema)).toEqual({ direction: "envio" });
});
});
describe("hasMissingRequiredFields", () => {
@ -247,4 +283,60 @@ describe("hasMissingRequiredFields", () => {
false,
);
});
// JSON Schema `required` constrains presence, not content — so an explicit
// `null` satisfies a required field whose schema admits null. Since #1928 the
// user can produce exactly that by clearing a nullable enum, and treating it
// as missing would disable submit on a value the schema calls valid.
it("accepts an explicit null for a required field that admits null", () => {
const nullable: InspectorFormSchema = {
type: "object",
properties: {
direction: {
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
},
},
required: ["direction"],
};
expect(hasMissingRequiredFields(nullable, { direction: null })).toBe(false);
expect(hasMissingRequiredFields(nullable, {})).toBe(true);
});
it("accepts an explicit null for a required type: [T, null] field", () => {
const nullable: InspectorFormSchema = {
type: "object",
properties: { note: { type: ["string", "null"] } },
required: ["note"],
};
expect(hasMissingRequiredFields(nullable, { note: null })).toBe(false);
});
it("still rejects null for a required field whose schema is not nullable", () => {
expect(hasMissingRequiredFields(schema, { name: null })).toBe(true);
});
// The renderer's collapse only handles a two-member union, but null admission
// has no such limit — this one renders through the JSON fallback, where the
// user can still type `null`, and the schema plainly accepts it.
it("accepts an explicit null for a required three-member union", () => {
const wide: InspectorFormSchema = {
type: "object",
properties: {
mixed: {
anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }],
},
},
required: ["mixed"],
};
expect(hasMissingRequiredFields(wide, { mixed: null })).toBe(false);
});
it("rejects null for a required field the schema does not describe", () => {
const undescribed: InspectorFormSchema = {
type: "object",
properties: {},
required: ["ghost"],
};
expect(hasMissingRequiredFields(undescribed, { ghost: null })).toBe(true);
});
});

View file

@ -1,3 +1,8 @@
import {
admitsNull,
normalizeNullableUnion,
} from "@inspector/core/json/nullableUnion.js";
export type JsonValue =
| string
| number
@ -116,7 +121,12 @@ export function collectSchemaDefaults(
): Record<string, unknown> {
const properties = schema.properties ?? {};
const result: Record<string, unknown> = {};
for (const [fieldName, fieldSchema] of Object.entries(properties)) {
for (const [fieldName, rawSchema] of Object.entries(properties)) {
// Collapse a nullable union first, for the same reason `SchemaForm` does:
// a nested object's `properties` live on the union's surviving branch, so
// without this the form would *display* a hoisted default that never
// reached the seeded values — the field would submit empty (#1928).
const fieldSchema = normalizeNullableUnion(rawSchema);
if (fieldSchema.default !== undefined) {
result[fieldName] = fieldSchema.default;
} else if (fieldSchema.type === "object" && fieldSchema.properties) {
@ -133,15 +143,33 @@ export function collectSchemaDefaults(
* Whether any of the schema's required top-level fields is missing a value in
* `values` (absent, null, or empty string). Used to gate a form's submit
* action until required fields are supplied.
*
* `null` counts as missing only for a field that does not admit it. JSON
* Schema's `required` constrains *presence*, not content, so a required field
* whose schema is a nullable union is satisfied by an explicit `null` — and
* since #1928 the user can produce exactly that by clearing a nullable enum.
* Treating it as missing would leave the form's submit permanently disabled on
* a value the schema calls valid.
*
* The test is `admitsNull`, **not** whether the renderer's collapse recognized
* the field. Those differ: the collapse only handles a two-member union, so a
* three-member `anyOf: [string, number, null]` renders through the JSON
* fallback — where a user can still type `null` — while plainly admitting it.
* Gating on the collapse would reject a value the schema accepts.
*/
export function hasMissingRequiredFields(
schema: InspectorFormSchema,
values: Record<string, unknown>,
): boolean {
const required = schema.required ?? [];
const properties = schema.properties ?? {};
return required.some((field) => {
const value = values[field];
return value === undefined || value === null || value === "";
if (value === null) {
const fieldSchema = properties[field];
return fieldSchema === undefined ? true : !admitsNull(fieldSchema);
}
return value === undefined || value === "";
});
}

View file

@ -8,7 +8,6 @@ import {
generateDefaultValue,
isPropertyRequired,
resolveRef,
normalizeUnionType,
formatFieldLabel,
resolveRefsInMessage,
} from "./schemaUtils";
@ -243,112 +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);
});
});
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,107 +201,6 @@ export function resolveRef(
return schema;
}
/**
* Normalizes union types (like string|null from FastMCP) to simple types for form rendering
* @param schema The JSON schema to normalize
* @returns A normalized schema or the original schema
*/
export function normalizeUnionType(
schema: InspectorFormSchema,
): InspectorFormSchema {
// Handle anyOf with exactly string and null (FastMCP pattern)
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "string") &&
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null")
) {
return { ...schema, type: "string", anyOf: undefined, nullable: true };
}
// Handle anyOf with exactly boolean and null (FastMCP pattern)
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "boolean") &&
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null")
) {
return { ...schema, type: "boolean", anyOf: undefined, nullable: true };
}
// Handle anyOf with exactly number and null (FastMCP pattern)
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "number") &&
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null")
) {
return { ...schema, type: "number", anyOf: undefined, nullable: true };
}
// Handle anyOf with exactly integer and null (FastMCP pattern)
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "integer") &&
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null")
) {
return { ...schema, type: "integer", anyOf: undefined, nullable: true };
}
// Handle anyOf with exactly array and null (FastMCP pattern)
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "array") &&
schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null")
) {
return { ...schema, type: "array", anyOf: undefined, nullable: true };
}
// Handle array type with exactly string and null
if (
Array.isArray(schema.type) &&
schema.type.length === 2 &&
schema.type.includes("string") &&
schema.type.includes("null")
) {
return { ...schema, type: "string", nullable: true };
}
// Handle array type with exactly boolean and null
if (
Array.isArray(schema.type) &&
schema.type.length === 2 &&
schema.type.includes("boolean") &&
schema.type.includes("null")
) {
return { ...schema, type: "boolean", nullable: true };
}
// Handle array type with exactly number and null
if (
Array.isArray(schema.type) &&
schema.type.length === 2 &&
schema.type.includes("number") &&
schema.type.includes("null")
) {
return { ...schema, type: "number", nullable: true };
}
// Handle array type with exactly integer and null
if (
Array.isArray(schema.type) &&
schema.type.length === 2 &&
schema.type.includes("integer") &&
schema.type.includes("null")
) {
return { ...schema, type: "integer", nullable: true };
}
return schema;
}
/**
* Formats a field key into a human-readable label
* @param key The field key to format
@ -339,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];
},
),

502
core/json/nullableUnion.ts Normal file
View file

@ -0,0 +1,502 @@
/**
* Flattening of the two JSON Schema encodings of "this type, **or** null" into
* the plain type a form renderer can dispatch on.
*
* Shared because both form builders — the web client's `SchemaForm` and the
* TUI's `schemaToForm` — dispatch on a *single* `type` string, and so both miss
* a nullable field entirely without this step (#1928, #2015). Keeping one copy
* is what stops the two clients from disagreeing about which schemas they can
* render; the alternative was two implementations of the same subtle predicate.
*/
/**
* JSON Schema type names a form renderer can build a widget for.
*
* `"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;
/** A `type` name the collapse is willing to produce. */
export type RenderableType = (typeof RENDERABLE_TYPES)[number];
function isRenderableType(type: unknown): type is RenderableType {
return RENDERABLE_TYPES.includes(type as RenderableType);
}
/**
* The keywords {@link normalizeNullableUnion} reads. Both clients' own schema
* types are structurally assignable to this, so neither has to adopt the
* other's — the shape is deliberately the minimum needed to *recognize* a
* nullable union, not a full JSON Schema model.
*/
export interface NullableUnionSchema {
type?: string | string[];
enum?: unknown[];
anyOf?: readonly unknown[];
// Read only by `admitsNull`, which considers encodings the collapse itself
// declines to flatten, and the sibling constraints that can rule null out.
oneOf?: readonly unknown[];
allOf?: readonly unknown[];
not?: unknown;
$ref?: string;
nullable?: boolean;
const?: unknown;
}
/** Narrow an `anyOf` member to a readable object, or `null` if it isn't one. */
function toBranch(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
/**
* Whether an `enum` is one that can be rendered as a string-valued dropdown.
*
* JSON Schema's `enum` is untyped — `[1, 2]`, `[true, false]`, and `[null]` are
* all legal — so a bare `{ enum: [...] }` does **not** imply strings. Guessing
* `"string"` for a numeric enum would hand non-strings to a renderer that has
* declared them `string[]`: the web `Select` would receive numbers, and the TUI
* would `String(...)` them and submit `"1"` where the server expects `1`. So
* only an all-string enum earns the inference; anything else stays on the
* fallback path, which renders the value honestly as JSON.
*
* Exported because the same question decides whether a *dispatcher* may route
* an enum to a select at all. The TUI's does, and its options are stringified,
* so an unguarded numeric enum there submits `"1"` where the server wants `1`.
*/
export function isStringEnum(value: unknown): value is string[] {
return (
Array.isArray(value) &&
value.length > 0 &&
value.every((member) => typeof member === "string")
);
}
/**
* The result of a collapse that actually matched. Spelled out rather than
* reusing `T` because the collapse genuinely changes two of `T`'s fields:
* `type` becomes a single renderable name (never an array, never `"null"`),
* and `anyOf` is cleared. Returning `T` for these would let a caller whose `T`
* types `type` as an array go on treating it as one after it has become a
* string, or dereference an `anyOf` that is now `undefined`.
*
* Note this cannot make the *hoist* sound: an `anyOf` branch is `unknown`, so
* any keyword lifted off it is whatever the server sent, however `T` declares
* it. {@link isStringEnum} validates the one keyword the renderers dereference
* as a typed array (`enum`); the rest reach widgets that read them defensively.
*/
export type NormalizedNullableUnion<T extends NullableUnionSchema> = Omit<
T,
"type" | "anyOf"
> & {
type?: RenderableType;
anyOf?: undefined;
nullable?: boolean;
};
/**
* Whether a schema's **own** `enum`/`const` rules `null` out, regardless of what
* its `type` or union branches say.
*
* JSON Schema keywords at one level are **conjunctive** — a value must satisfy
* all of them — so a syntactic null does not by itself mean the schema accepts
* null. `{ type: ["string", "null"], enum: ["envio"] }` names `"null"` in its
* type list and still rejects `null`, because the `enum` does not offer it.
* Marking that field nullable would give it a clear button that emits a value
* the schema forbids, and would make required-field gating accept it.
*
* Evaluated against the schema's own level only. A branch's `enum` is *not* a
* sibling — in `anyOf: [{ type: "string", enum: [...] }, { type: "null" }]` the
* enum constrains that branch alone, and the union still permits null. Reading
* it as a sibling would break exactly the shape #1928 is about, so callers must
* pass the original schema here, never the hoisted merge.
*/
function nullExcludedBySiblings(schema: NullableUnionSchema): boolean {
if (Array.isArray(schema.enum) && !schema.enum.includes(null)) {
return true;
}
return schema.const !== undefined && schema.const !== null;
}
/**
* Keywords that only *describe* a field. Everything else constrains what values
* are valid.
*
* This is an **allowlist on purpose**, and the direction matters. The previous
* version enumerated the *validation* keywords and declined on a conflict among
* them, which meant any keyword nobody thought of — `oneOf`, `allOf`, `not`,
* `$ref`, a future vocabulary — was silently treated as safe to drop. A missing
* entry there fails open, and open means rendering a widget for a constraint
* that is not the schema's. Listing the harmless keys instead fails closed: an
* unrecognized keyword sends the field to the JSON editor, which is the correct
* answer for a schema this module does not understand.
*/
const ANNOTATION_KEYWORDS = new Set([
"title",
"description",
"default",
"enumNames",
"examples",
"deprecated",
"readOnly",
"writeOnly",
"$comment",
"$schema",
"$id",
]);
/**
* Whether hoisting a branch would *drop* something the wrapper constrains.
*
* The hoist is a spread, so a branch key replaces the wrapper's — but JSON
* Schema applies sibling keywords **conjunctively**, and a replacement is not a
* conjunction. A wrapper `enum: ["a"]` around a branch `enum: ["a", "b"]` means
* `"a"` (both must hold), yet the spread yields `["a", "b"]` and the rendered
* dropdown would offer — and submit — a `"b"` the schema rejects. `type`,
* bounds, the object keywords, and the applicators (`oneOf`, `allOf`, `not`)
* all fail the same way.
*
* Rather than intersect them — a much larger surface, and one where a subtly
* wrong intersection is worse than none because it renders a *plausible* widget
* for the wrong constraint — the collapse **declines** whenever the wrapper
* carries anything beyond annotations and the union itself. The field then
* renders through the JSON editor with its full schema intact.
*
* This is stricter than comparing values for equality: a wrapper that merely
* restates its branch's `type` now also declines. That costs a dropdown on a
* rare redundant shape and buys a rule with no gap in it, which is the better
* trade for a module that cannot evaluate JSON Schema.
*/
function wrapperCarriesConstraints(schema: NullableUnionSchema): boolean {
return Object.keys(schema).some(
(key) => key !== "anyOf" && !ANNOTATION_KEYWORDS.has(key),
);
}
/** Whether a schema's `type` names `"null"` at all. */
function typeNamesNull(type: unknown): boolean {
return Array.isArray(type) ? type.includes("null") : type === "null";
}
/**
* Applicators this module does not evaluate, and which are **conjunctive** with
* everything beside them — so any of them can rule `null` out, or constrain a
* value in a way no widget here would honor.
*
* `anyOf` is excluded because it is the keyword being *recognized*: a caller
* that has already identified the nullable-union shape checks it directly. Every
* other position treats a stray `anyOf` as opaque too.
*/
function hasOpaqueApplicator(schema: NullableUnionSchema): boolean {
return (
schema.not !== undefined ||
schema.allOf !== undefined ||
schema.oneOf !== undefined ||
// `$ref` points at a schema this module never resolves, and it applies
// *alongside* its siblings — `{ type: "string", $ref: "#/$defs/intOnly" }`
// can be unsatisfiable. The wrapper path already declines on it (it is not
// an annotation); this is the same rule for every other position.
schema.$ref !== undefined
);
}
/**
* Whether a schema composes further constraints this module cannot evaluate —
* {@link hasOpaqueApplicator}, **plus a nested `anyOf`**.
*
* This is the single test every position uses on a schema that is *not* the
* union being recognized: the surviving branch, a null branch, and the
* `type: [T, "null"]` schema. Each of those was missed independently at some
* point — the guard existed but was spelled out inline and drifted between
* sites — so they now share one predicate rather than three conditions that
* have to be kept in step.
*
* A nested `anyOf` matters as much as the others because the hoist *drops* it:
* the branch's `anyOf` overwrites the wrapper's in the spread and is then
* cleared, so `{ anyOf: [{ type: "string", anyOf: [...] }, { type: "null" }] }`
* would otherwise widen into an unconstrained nullable string.
*/
function hasUnevaluatedComposition(schema: NullableUnionSchema): boolean {
return schema.anyOf !== undefined || hasOpaqueApplicator(schema);
}
/**
* What {@link stripNullEnumMembers} concluded about an `enum`.
*
* `"unselectable"` is a distinct outcome rather than "an empty list" because it
* has to **stop the collapse**, not just empty a field. See below.
*/
type EnumStrip =
| { kind: "unchanged" }
| { kind: "filtered"; members: unknown[]; names?: unknown }
| { kind: "unselectable" };
/**
* Drop `null` members from an `enum`, keeping the parallel `enumNames` aligned.
*
* The `type: [T, "null"]` encoding keeps its keywords at the top level, so a
* nullable enum written that way carries the null *inside* the list:
* `{ type: ["string", "null"], enum: ["envio", "recebimento", null] }`. The
* collapse has already moved that fact onto `nullable`, so leaving the sentinel
* in the list breaks both renderers in different ways — the web dispatcher
* would hand `null` to Mantine as option data, and the TUI's all-strings check
* would reject the whole enum and fall back to a plain text field.
*
* **`enumNames` is filtered by the same indices, not left alone.** It is a
* positional parallel array, and both renderers discard labels outright when
* the two lengths disagree — so stripping one without the other silently loses
* every label rather than just the dropped one's.
*
* **An enum that offers no selectable value reports `"unselectable"`, so the
* caller declines to collapse at all.** That covers `[null]` and the empty
* `[]`: the first permits only `null`, the second permits nothing. Emitting
* `enum: undefined` instead would turn either into a plain string field
* accepting arbitrary text — trading a cosmetic problem for a correctness one,
* since the form would then invite values the schema forbids. Left uncollapsed
* they render through the JSON editor, which represents them honestly.
*/
function stripNullEnumMembers(members: unknown, names: unknown): EnumStrip {
if (!Array.isArray(members)) {
return { kind: "unchanged" };
}
const kept = members
.map((member, index) => ({ member, index }))
.filter((entry) => entry.member !== null);
// An empty `enum` permits nothing at all, so it reaches the same conclusion
// as `[null]` by a different route: there is no value a dropdown could offer.
if (kept.length === 0) {
return { kind: "unselectable" };
}
if (kept.length === members.length) {
return { kind: "unchanged" };
}
// Only realign names that were positionally parallel to begin with, and DROP
// them otherwise. Keeping a mismatched list is not neutral: filtering shortens
// `enum`, so `enum: [null, "b"]` with `enumNames: ["None"]` would come out the
// same length and start *labelling* `b` as "None" — turning labels both
// renderers had correctly ignored into a confident mislabel.
const wasParallel = Array.isArray(names) && names.length === members.length;
const aligned = wasParallel
? kept.map((entry) => names[entry.index])
: undefined;
return {
kind: "filtered",
members: kept.map((entry) => entry.member),
names: aligned,
};
}
/**
* Build the collapsed schema.
*
* The assertion is needed because the spread merges an unresolved generic `T`
* with an `unknown` branch, and `NormalizedNullableUnion<T>`'s `Omit` stays
* deferred while `T` is open — so TS cannot verify the two line up even though
* every field is set right here. Isolated in this one function so the exported
* signature carries the contract and nothing else has to assert it.
*
* Returns `null` when the merged schema turns out to permit only `null`, which
* is not collapsible; see {@link stripNullEnumMembers}.
*/
function collapsed<T extends NullableUnionSchema>(
schema: T,
branch: Record<string, unknown> | undefined,
type: RenderableType,
): NormalizedNullableUnion<T> | null {
const merged = { ...schema, ...branch };
// Read after the merge so the branch's list wins when it has one.
const strip = stripNullEnumMembers(
merged.enum,
(merged as { enumNames?: unknown }).enumNames,
);
if (strip.kind === "unselectable") {
return null;
}
return {
...merged,
type,
anyOf: undefined,
// Read off the ORIGINAL schema, not the merge: a hoisted branch `enum` is
// not a sibling constraint, and treating it as one would mark the very
// shape #1928 is about as non-nullable. See `nullExcludedBySiblings`.
nullable: admitsNull(schema),
...(strip.kind === "filtered"
? { enum: strip.members, enumNames: strip.names }
: {}),
} as NormalizedNullableUnion<T>;
}
/**
* Whether a schema permits an explicit `null`.
*
* Deliberately **independent of {@link normalizeNullableUnion}**, which is a
* *renderer* question — "can this become one widget?" — and is therefore
* narrower on purpose: it only collapses a two-member union. Null admission is
* a *validity* question and has no such limit, so
* `anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }]` admits
* null even though it renders through the JSON fallback. Deriving one from the
* other would make a form reject a value its own schema accepts, which is why
* these are two functions rather than a `.nullable` flag read off the collapse.
*
* **Answers `false` for anything it cannot fully evaluate**, which is a larger
* set than it may look. Recognized: `nullable: true`, `type: "null"`,
* `type: [..., "null"]`, and a `"null"` branch in an `anyOf` of any size.
* Deliberately *not* recognized, despite naming null:
*
* - **`oneOf`** — it requires *exactly one* branch to match, so a `null` branch
* does not mean `null` validates; two matching branches make it fail.
* - **`not` / `allOf`**, at this level or on the branch, which can rule null out.
* - **A sibling `anyOf` beside an explicit `type`**, since the two are
* conjunctive and the union is not evaluated here.
* - **A branch that names null but is unsatisfiable**, whether by its own
* `enum`/`const` or its own nested applicators.
*
* The asymmetry is what settles the direction: under-claiming costs a clear
* button and treats a valid `null` as missing, while over-claiming lets the
* form emit a value the schema rejects. So callers get a firm "no" rather than
* an optimistic guess, and a schema this module cannot read renders through the
* JSON editor with its constraints intact.
*/
export function admitsNull(schema: NullableUnionSchema): boolean {
if (nullExcludedBySiblings(schema)) {
return false;
}
// Applicators this module does not evaluate. `not: { type: "null" }` rules
// null out; `allOf` can add a member that does; and `oneOf` requires
// **exactly one** branch to match, so a null branch does not by itself mean
// `null` validates — two matching branches make it fail. Rather than answer
// these half-correctly, say no: under-claiming nullability costs a clear
// button, while over-claiming lets the form emit a value the schema rejects.
if (hasOpaqueApplicator(schema)) {
return false;
}
if (schema.nullable === true) {
return true;
}
// An explicit `type` is a sibling constraint too, so it *decides* — it does
// not merely add a way to say yes. `{ type: "string", anyOf: [..., { type:
// "null" }] }` rejects null, because a value must satisfy the `type` as well
// as the union. Falling through to the branch scan here would let the union
// override a constraint that outranks it.
if (schema.type !== undefined) {
// A sibling `anyOf` is conjunctive with the `type`, so it can reject null
// even when the type list names it — `{ type: ["string", "null"], anyOf:
// [{ type: "string" }] }` admits no null at all. Not evaluated here, so
// its mere presence withholds the claim.
if (schema.anyOf !== undefined) {
return false;
}
return typeNamesNull(schema.type);
}
return (
schema.anyOf?.some((entry) => {
const branch = toBranch(entry);
if (branch === null || !typeNamesNull(branch.type)) {
return false;
}
// A branch that names null can still admit nothing: `{ type: "null",
// const: "x" }` is unsatisfiable, and its own applicators are as opaque
// here as the wrapper's.
const branchSchema = branch as NullableUnionSchema;
// A branch that names null can still admit nothing: `{ type: "null",
// const: "x" }` is unsatisfiable, and a nested union or applicator inside
// it is as opaque here as one on the wrapper.
return (
!nullExcludedBySiblings(branchSchema) &&
!hasUnevaluatedComposition(branchSchema)
);
}) ?? false
);
}
/**
* Collapse a nullable union — what Zod's `.nullish()` / `.nullable()` and
* FastMCP's optional arguments emit — into `{ type: <T>, nullable: true }`.
*
* Two encodings mean the same thing and are both handled:
*
* - `anyOf: [<branch>, { type: "null" }]` — the branch's *own* keywords are
* hoisted onto the result, because that is where the detail a renderer needs
* lives. A nullable enum compiles to
* `anyOf: [{ type: "string", enum: [...] }, { type: "null" }]`, so hoisting
* `enum` is what makes it a dropdown rather than a raw-JSON fallback (#1928).
* The branch also wins over the wrapper on any shared key, matching v1.x.
* - `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 **by identity**, so a caller can use
* `===` to tell that nothing was recognized.
*
* @param schema The schema to normalize
* @returns A flattened copy, or `schema` itself when no nullable-union pattern
* matches
*/
export function normalizeNullableUnion<T extends NullableUnionSchema>(
schema: T,
): T | NormalizedNullableUnion<T> {
if (schema.anyOf?.length === 2) {
const branches = schema.anyOf.map(toBranch);
const nullBranch = branches.find((entry) => entry?.type === "null");
const branch = branches.find(
(entry) => entry !== null && entry.type !== "null",
);
// Hoisting would silently drop a wrapper constraint the branch also
// carries, so decline instead; see `branchDropsWrapperConstraint`.
// The wrapper may only carry annotations, and the surviving branch may not
// compose anything this module cannot evaluate — the hoist would drop it.
if (
nullBranch &&
branch &&
!wrapperCarriesConstraints(schema) &&
!hasUnevaluatedComposition(branch as NullableUnionSchema)
) {
// A branch may carry an `enum` and no `type`; JSON Schema allows that, and
// an all-string enum is unambiguously a string field. See isStringEnum for
// why a non-string enum deliberately does not get the same treatment.
const type =
branch.type ?? (isStringEnum(branch.enum) ? "string" : undefined);
if (isRenderableType(type)) {
// `null` when the merged enum offers nothing selectable, which is not
// collapsible — fall through and return the schema by identity.
const result = collapsed(schema, branch, type);
if (result !== null) {
return result;
}
}
}
}
if (
Array.isArray(schema.type) &&
schema.type.length === 2 &&
schema.type.includes("null") &&
// This path keeps the schema's own keywords, but `collapsed` clears `anyOf`
// and the renderers ignore the other applicators — so a compound schema
// like `{ type: ["string", "null"], anyOf: [{ const: "a" }] }` would be
// widened into an unconstrained string field. Leave it for the JSON editor.
!hasUnevaluatedComposition(schema)
) {
const type = schema.type.find((member) => member !== "null");
if (isRenderableType(type)) {
const result = collapsed(schema, undefined, type);
if (result !== null) {
return result;
}
}
}
return schema;
}

View file

@ -0,0 +1,11 @@
{
"serverInfo": {
"name": "nullable-fields-showcase",
"version": "1.0.0"
},
"tools": [{ "preset": "record_shipment" }, { "preset": "get_temp" }],
"transport": {
"type": "streamable-http",
"port": 6602
}
}

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.