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:
commit
71aa4b6b1d
16 changed files with 2449 additions and 252 deletions
10
AGENTS.md
10
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
734
clients/web/src/test/core/nullableUnion.test.ts
Normal file
734
clients/web/src/test/core/nullableUnion.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
148
clients/web/src/test/integration/mcp/nullable-fields.test.ts
Normal file
148
clients/web/src/test/integration/mcp/nullable-fields.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 === "";
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
502
core/json/nullableUnion.ts
Normal 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;
|
||||
}
|
||||
11
test-servers/configs/nullable-fields-http.json
Normal file
11
test-servers/configs/nullable-fields-http.json
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue