fix: address Copilot review round 3 — typed consts, null admission, preset coverage

Three findings (one inline, two in the suppressed block), all valid.

1. `toConstOptions` still stringified every non-null `const`, so
   `items: { anyOf: [{ const: 1 }, { const: 2 }] }` submitted `["1"]` where
   the schema says `[1]`. Same wrong-type-on-the-wire problem that keeps a
   numeric `enum` off the select path, so it gets the same answer: only a
   string `const` is selectable, anything else falls to the JSON editor
   where the value keeps its type.

2. `hasMissingRequiredFields` derived null admission from the *renderer's*
   collapse, which is narrower on purpose — it only flattens a two-member
   union. So `anyOf: [string, number, null]` renders through the JSON
   fallback (where `null` is typeable) yet was still marked missing,
   disabling submit on a value the schema accepts. Null admission is a
   validity question, not a rendering one, so it is now its own predicate:
   `admitsNull` in core, recognizing `nullable`, `type: "null"`,
   `type: [..., "null"]`, and a null branch anywhere in an `anyOf`/`oneOf`
   of any size.

3. Nothing exercised the `record_shipment` preset — the web and TUI tests
   build the `anyOf` shape by hand, which verifies the renderers but
   *assumes* the premise the whole fix rests on: that Zod's `.nullish()`
   emits `anyOf: [<branch>, { type: "null" }]` with the enum on the branch.
   A Zod output change or a registry typo would leave every unit test green
   while the documented reproduction server quietly stopped reproducing.
   Added an integration test that boots the preset over HTTP, asserts the
   wire shape, and runs it back through the collapse — following the
   `duplicate-tool-names.test.ts` precedent, which exists for the same
   reason (#1957).

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 18:15:38 -04:00
commit 34ba8256a4
7 changed files with 271 additions and 10 deletions

View file

@ -1022,6 +1022,26 @@ describe("SchemaForm nullable unions", () => {
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",

View file

@ -60,10 +60,14 @@ function toEnumData(
* 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. Three ways that happens, all of which
* Mantine reacts to badly:
* field to the JSON fallback instead. Four ways that happens:
*
* - **No `const`.** An `anyOf` of *object* schemas — what
* - **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
@ -80,10 +84,10 @@ function toConstOptions(
const options: { value: string; label: string }[] = [];
const seen = new Set<string>();
for (const branch of branches) {
if (branch.const === undefined || branch.const === null) {
if (typeof branch.const !== "string") {
return null;
}
const value = String(branch.const);
const value = branch.const;
if (value === "" || seen.has(value)) {
return null;
}

View file

@ -1,5 +1,8 @@
import { describe, it, expect } from "vitest";
import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js";
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
@ -281,3 +284,48 @@ describe("normalizeNullableUnion", () => {
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 in a oneOf, and a nested type array", () => {
expect(admitsNull({ oneOf: [{ type: "string" }, { type: "null" }] })).toBe(
true,
);
expect(admitsNull({ anyOf: [{ type: ["string", "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);
});
});

View file

@ -0,0 +1,122 @@
import { describe, it, expect, afterEach } from "vitest";
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 {
createTestServerHttp,
type TestServerHttp,
createTestServerInfo,
createNullableFieldsTool,
} from "@modelcontextprotocol/inspector-test-server";
/**
* Live coverage of the `record_shipment` preset behind
* `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, or a
* typo in the preset registry, would leave every unit test green while the
* showcase server quietly stopped reproducing the bug.
*
* So this asserts the wire shape end to end, then runs it through the same
* collapse the forms use.
*/
describe("nullable argument schemas over the wire (#1928)", () => {
let client: InspectorClient | null = null;
let server: TestServerHttp | null = null;
afterEach(async () => {
if (client) {
try {
await client.disconnect();
} catch {
// ignore
}
client = null;
}
if (server) {
try {
await server.stop();
} catch {
// ignore
}
server = null;
}
});
async function connectToPreset(): Promise<InspectorClient> {
const started = createTestServerHttp({
serverInfo: createTestServerInfo("nullable-fields-test", "1.0.0"),
tools: [createNullableFieldsTool()],
});
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;
}
it("emits an anyOf-with-null for every nullish argument, enum on the branch", async () => {
const connected = await connectToPreset();
const { tools } = await connected.listAllTools();
const tool = tools.find((entry) => entry.name === "record_shipment");
expect(tool).toBeDefined();
const schema = toFormSchema(tool?.inputSchema);
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 connected = await connectToPreset();
const { tools } = await connected.listAllTools();
const schema = toFormSchema(
tools.find((entry) => entry.name === "record_shipment")?.inputSchema,
);
const properties = schema?.properties ?? {};
const collapsed = Object.fromEntries(
Object.entries(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

@ -315,6 +315,22 @@ describe("hasMissingRequiredFields", () => {
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",

View file

@ -1,4 +1,7 @@
import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js";
import {
admitsNull,
normalizeNullableUnion,
} from "@inspector/core/json/nullableUnion.js";
export type JsonValue =
| string
@ -147,6 +150,12 @@ export function collectSchemaDefaults(
* 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,
@ -158,9 +167,7 @@ export function hasMissingRequiredFields(
const value = values[field];
if (value === null) {
const fieldSchema = properties[field];
return fieldSchema === undefined
? true
: normalizeNullableUnion(fieldSchema).nullable !== true;
return fieldSchema === undefined ? true : !admitsNull(fieldSchema);
}
return value === undefined || value === "";
});

View file

@ -42,6 +42,10 @@ export interface NullableUnionSchema {
type?: string | string[];
enum?: unknown[];
anyOf?: readonly unknown[];
// Read only by `admitsNull`, which considers encodings the collapse itself
// declines to flatten.
oneOf?: readonly unknown[];
nullable?: boolean;
}
/** Narrow an `anyOf` member to a readable object, or `null` if it isn't one. */
@ -120,6 +124,46 @@ function collapsed<T extends NullableUnionSchema>(
} 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.
*
* Recognizes every encoding the collapse does, plus the ones it declines:
* `nullable: true`, `type: "null"`, `type: [..., "null"]`, and a `"null"`
* branch anywhere in an `anyOf` or `oneOf` of any size.
*/
export function admitsNull(schema: NullableUnionSchema): boolean {
if (schema.nullable === true) {
return true;
}
if (schema.type === "null") {
return true;
}
if (Array.isArray(schema.type) && schema.type.includes("null")) {
return true;
}
return [schema.anyOf, schema.oneOf].some((branches) =>
branches?.some((entry) => {
const branch = toBranch(entry);
if (branch === null) {
return false;
}
return (
branch.type === "null" ||
(Array.isArray(branch.type) && branch.type.includes("null"))
);
}),
);
}
/**
* Collapse a nullable union — what Zod's `.nullish()` / `.nullable()` and
* FastMCP's optional arguments emit — into `{ type: <T>, nullable: true }`.