fix: render tool schemas with root-level anyOf/oneOf composition

A tool's `inputSchema` may carry composition keywords at its root — the
2026-07-28 revision makes this explicit — but both form builders enumerate
the root's `properties` and nothing else. A schema keeping its fields on
`allOf`/`oneOf`/`anyOf` branches therefore rendered no controls at all: no
branch picker, no fields, not even the raw-JSON fallback a union-typed
*property* falls back to, so the tool could only be called with empty
arguments.

Adds `core/json/rootUnion.ts`, one flattening shared by all three clients so
they cannot drift on which schemas they can render:

- `allOf` is merged unconditionally (conjunctive — there is no choice to
  present), a root `oneOf`/`anyOf` is returned as branches.
- Web `SchemaForm` renders a Variant picker above the fields and prunes the
  outgoing branch's values on a switch, since they are no longer visible and
  would describe a shape the call is not making.
- TUI `schemaToForm` gives each branch its own section with optional fields —
  ink-form is static, and only one alternative applies to a call.
- `convertToolParameters` looks through the branches, so a CLI `--tool-arg`
  is typed by the schema that declares it rather than sent as a string.

Required-field gating asks whether *any* branch is satisfied, which is
selection-independent and never blocks arguments the schema accepts. A
`const` is seeded like a `default` and rendered read-only: it admits exactly
one value, so anything else the user could type produces a rejected call.

Adds a `root-union-schemas-http.json` showcase server.

Closes #2123

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 00:37:43 -04:00
commit 5933162dd0
16 changed files with 1259 additions and 29 deletions

View file

@ -158,6 +158,25 @@ v2/main/
│ │ # and TUI schemaToForm — since each dispatches on
│ │ # a single `type` string and would otherwise miss
│ │ # a nullable field entirely — #1928/#2015;
│ │ # rootUnion.ts: flattens the COMPOSITION keywords a
│ │ # tool's inputSchema may carry at its ROOT — `allOf`
│ │ # merged unconditionally (conjunctive, so there is
│ │ # no choice to present), a root `oneOf`/`anyOf`
│ │ # returned as the BRANCHES a picker chooses between.
│ │ # Legal since 2026-07-28 and rendered as an EMPTY
│ │ # FORM before — no picker, no fields, not even the
│ │ # raw-JSON fallback a union-typed *property* gets,
│ │ # so the tool could only be called with empty
│ │ # arguments. Read by all three clients: web
│ │ # SchemaForm (the Variant picker + the branch-change
│ │ # value pruning), TUI schemaToForm (a section per
│ │ # branch, its fields forced OPTIONAL since only one
│ │ # alternative applies), and convertToolParameters
│ │ # (which branch's schema types a CLI --tool-arg).
│ │ # Declines a union whose members are not ALL
│ │ # field-carrying objects rather than offering a
│ │ # picker with options that render nothing, and does
│ │ # not interpret `not` at all — #2123;
│ │ # schemaLint.ts: tool-schema PORTABILITY lint —
│ │ # constructs that are legal JSON Schema and are
│ │ # refused or mishandled by real MCP clients (a bare

View file

@ -35,8 +35,9 @@ inspector/
│ │ # implementations, the selection policy, and the descriptor the banner and UI report
│ ├── client/ # Install-level client config (`client.json`): browser-safe parse/validate + Node load/save, remote backend, secrets
│ ├── json/ # JSON + parameter/argument conversion utilities, the nullable-union
│ │ # schema collapse shared by the web and TUI form builders, and the
│ │ # tool-schema portability lint all three clients report from
│ │ # schema collapse and root-composition flattening shared by the web
│ │ # and TUI form builders, and the tool-schema portability lint all
│ │ # three clients report from
│ ├── logging/ # Silent pino logger singleton
│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import,
│ │ # and the RFC 6570 URI-template helpers the web form and TUI expand through
@ -153,6 +154,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
| `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) |
| `root-union-schemas-http.json` **(legacy era)** | Tool schemas whose arguments are a root `anyOf` / `oneOf` | [#2123](https://github.com/modelcontextprotocol/inspector/issues/2123) |
| `unportable-schemas-http.json` **(legacy era)** | Tool schemas a real client rejects, flagged in all three clients | [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005) |
| `rfc6570-templates-http.json` | Resources tab: RFC 6570 resource-template expansion | [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) |
| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) |
@ -288,6 +290,22 @@ Open the Tools tab and select `record_shipment`: `direction` must render as a **
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.
#### Root-level unions
`root-union-schemas-http.json` serves two tools whose arguments are declared as a **composition at the root** of `inputSchema` rather than as a flat `properties` map — `echo` with an `anyOf` beside its own `message` property, and `get_weather` with an OpenAPI-style `discriminator` over a `oneOf`. Plain streamable-HTTP — connect with the **default (legacy)** protocol era.
The 2026-07-28 revision makes this shape explicitly legal: `type: "object"` is required at the root, and beyond that "any JSON Schema 2020-12 keyword may appear alongside `type`, including composition keywords (`oneOf`, `anyOf`, `allOf`, `not`)".
Open the Tools tab and select `echo`. Above the fields is a **Variant** picker listing the union's alternatives — labelled from each branch's `title`, else its discriminator `const`, else its position — and choosing one swaps in that branch's fields with the discriminator already filled in. On the broken build both tools rendered **nothing but the Execute Tool button**: no picker, no fields, not even the raw-JSON editor a union-typed _property_ falls back to, so neither tool could be called with anything but empty arguments ([#2123](https://github.com/modelcontextprotocol/inspector/issues/2123)).
Switching branches drops the values that belonged to the outgoing one. They are no longer on screen, so the user can neither see nor clear them, and submitting them would describe a shape the call is not making.
The **TUI** has the same gap and is worth checking against the same server (`--tui`, then test `echo`). ink-form is static — there is no picker to hide the alternatives behind — so each branch becomes its own **section**, and the fields in it are rendered optional whatever the branch says: only one alternative applies to a call, so requiring them would build a form that can never be submitted. Untouched fields report no value and are dropped before the call, so the sections you skip contribute nothing.
The **CLI** has no form at all, but the same flattening decides how `--tool-arg` values are typed: a branch's `count: { "type": "number" }` is what turns `--tool-arg count=3` into `3` rather than `"3"`. All three read one helper, [`core/json/rootUnion.ts`](./core/json/rootUnion.ts), so they cannot drift on which schemas they can render.
Two things it deliberately does **not** do. A union whose members are not all field-carrying object schemas is left alone rather than offered as a picker with options that render nothing — the schema falls back to whatever its root `properties` describe. And `not` is not interpreted at all: there is no faithful form for "anything except this".
#### Unportable tool schemas
`unportable-schemas-http.json` serves four tools, three of whose advertised

View file

@ -408,4 +408,89 @@ describe("schemaToForm", () => {
});
});
});
describe("root composition (#2123)", () => {
const UNION = {
type: "object",
properties: { note: { type: "string" } },
discriminator: { propertyName: "kind" },
oneOf: [
{
type: "object",
properties: {
kind: { type: "string", const: "email" },
address: { type: "string" },
},
required: ["kind", "address"],
},
{
type: "object",
properties: {
kind: { type: "string", const: "sms" },
count: { type: "integer" },
},
required: ["kind", "count"],
},
],
};
it("gives each branch its own section instead of rendering no fields", () => {
const form = schemaToForm(UNION, "union_tool");
expect(form.sections.map((section) => section.title)).toEqual([
"Parameters",
"email",
"sms",
]);
expect(form.sections[0]!.fields.map((field) => field.name)).toEqual([
"note",
]);
expect(form.sections[1]!.fields.map((field) => field.name)).toEqual([
"kind",
"address",
]);
});
it("keeps a branch's typed fields typed", () => {
const form = schemaToForm(UNION, "union_tool");
expect(form.sections[2]!.fields[1]).toMatchObject({
name: "count",
type: "integer",
});
});
it("renders branch fields optional, since only one branch applies", () => {
const form = schemaToForm(UNION, "union_tool");
for (const field of form.sections[1]!.fields) {
expect(field.required).toBe(false);
}
});
it("seeds a branch's discriminator const so it need not be typed", () => {
const form = schemaToForm(UNION, "union_tool");
expect(form.sections[1]!.fields[0]).toMatchObject({
name: "kind",
initialValue: "email",
});
});
it("merges a root allOf into the parameters section", () => {
const form = schemaToForm(
{
type: "object",
properties: { a: { type: "string" } },
allOf: [{ type: "object", properties: { b: { type: "boolean" } } }],
},
"allof_tool",
);
expect(form.sections).toHaveLength(1);
expect(form.sections[0]!.fields.map((field) => field.name)).toEqual([
"a",
"b",
]);
});
it("still renders an empty form for a schema with no properties", () => {
const form = schemaToForm({ type: "object" }, "empty_tool");
expect(form.sections).toEqual([{ title: "Parameters", fields: [] }]);
});
});
});

View file

@ -7,6 +7,7 @@ import {
isStringEnum,
normalizeNullableUnion,
} from "@inspector/core/json/nullableUnion.js";
import { resolveRootUnion } from "@inspector/core/json/rootUnion.js";
/** Minimal JSON Schema property shape used when building tool parameter forms */
interface JsonSchemaProperty {
@ -24,6 +25,8 @@ interface JsonSchemaProperty {
minimum?: number;
maximum?: number;
default?: unknown;
/** A one-value enumeration; seeded like a `default` — see below. */
const?: unknown;
/** Present on a nullable union; see {@link normalizeNullableUnion}. */
anyOf?: readonly unknown[];
}
@ -54,6 +57,17 @@ function toSelectOptions(
interface JsonSchemaObject {
properties?: Record<string, unknown>;
required?: string[];
/**
* Root composition, read by {@link resolveRootUnion} before `properties` is
* enumerated (#2123). Members are `unknown` for the same reason property
* values are: the SDK's `Tool["inputSchema"]` types them as the recursive
* JSON type, and each is narrowed where it is used.
*/
type?: string | string[];
allOf?: readonly unknown[];
anyOf?: readonly unknown[];
oneOf?: readonly unknown[];
discriminator?: { propertyName?: string };
}
/**
@ -63,15 +77,44 @@ export function schemaToForm(
schema: JsonSchemaObject | null | undefined,
toolName: string,
): FormStructure {
const fields: FormField[] = [];
if (!schema || !schema.properties) {
return {
title: `Test Tool: ${toolName}`,
sections: [{ title: "Parameters", fields: [] }],
};
const title = `Test Tool: ${toolName}`;
if (!schema) {
return { title, sections: [{ title: "Parameters", fields: [] }] };
}
// Flatten root composition before reading `properties` (#2123). Without it a
// tool whose arguments are declared as a root `oneOf`/`anyOf` — legal since
// the 2026-07-28 revision — rendered a form with no fields at all, so it
// could only be called with empty arguments.
const { base, branches } = resolveRootUnion(schema);
const sections: FormSection[] = [
{ title: "Parameters", fields: buildFields(base) },
];
// ink-form is static — there is no branch picker to hide the alternatives
// behind — so every branch gets its own section and the user fills the one
// they mean. A branch's fields are rendered **optional** whatever the branch
// says: only one alternative applies to a given call, so requiring them would
// make a form that can never be submitted. An untouched field reports no
// value and is dropped before the call, so the sections the user skipped
// contribute nothing to the arguments.
for (const branch of branches) {
const ownProperties = Object.fromEntries(
branch.ownFields.map((name) => [name, branch.schema.properties?.[name]]),
);
sections.push({
title: branch.label,
fields: buildFields({ properties: ownProperties }),
});
}
return { title, sections };
}
/** Build the ink-form fields for one already-flattened object schema. */
function buildFields(schema: JsonSchemaObject): FormField[] {
const fields: FormField[] = [];
const properties = schema.properties || {};
const required = schema.required || [];
@ -160,24 +203,18 @@ export function schemaToForm(
}
}
// Set initial value from default (ink-form FormField allows initialValue for some types)
if (property.default !== undefined) {
// Set initial value from default (ink-form FormField allows initialValue for some types).
// A `const` is seeded the same way: it is a one-value enumeration, so the
// only submittable value is already known and the user would otherwise have
// to hand-type a union's discriminator (#2123).
const initialValue = property.default ?? property.const;
if (initialValue !== undefined) {
(field as FormField & { initialValue?: unknown }).initialValue =
property.default;
initialValue;
}
fields.push(field);
}
const sections: FormSection[] = [
{
title: "Parameters",
fields,
},
];
return {
title: `Test Tool: ${toolName}`,
sections,
};
return fields;
}

View file

@ -180,3 +180,41 @@ export const Disabled: Story = {
disabled: true,
},
};
/**
* Arguments declared as a composition at the root of the schema (#2123): a
* picker chooses the alternative, and its fields render beneath. Before this,
* such a schema produced a form with no controls at all.
*/
export const RootUnion: Story = {
args: {
schema: {
type: "object",
properties: {
message: { type: "string", title: "Message" },
},
required: ["message"],
anyOf: [
{
type: "object",
title: "By email",
properties: {
kind: { type: "string", const: "email" },
address: { type: "string", title: "Address" },
},
required: ["kind", "address"],
},
{
type: "object",
title: "By SMS",
properties: {
kind: { type: "string", const: "sms" },
phone: { type: "string", title: "Phone" },
},
required: ["kind", "phone"],
},
],
},
values: { kind: "email" },
},
};

View file

@ -1969,4 +1969,166 @@ describe("SchemaForm multiline strings (#2042)", () => {
);
expect(screen.getByRole("button", { name: "Enlarge Note" })).toBeDisabled();
});
describe("a root-level union (#2123)", () => {
const UNION_SCHEMA: InspectorFormSchema = {
type: "object",
properties: { note: { type: "string", title: "Note" } },
anyOf: [
{
type: "object",
properties: {
kind: { type: "string", const: "email" },
address: { type: "string", title: "Address" },
},
required: ["kind", "address"],
},
{
type: "object",
properties: {
kind: { type: "string", const: "sms" },
phone: { type: "string", title: "Phone" },
},
required: ["kind", "phone"],
},
],
};
it("renders the first branch's fields with a picker, not an empty form", () => {
renderWithMantine(
<SchemaForm schema={UNION_SCHEMA} values={{}} onChange={vi.fn()} />,
);
expect(screen.getByRole("textbox", { name: /Note/ })).toBeTruthy();
expect(screen.getByRole("textbox", { name: /Address/ })).toBeTruthy();
expect(screen.queryByRole("textbox", { name: /Phone/ })).toBeNull();
expect(
(screen.getByRole("textbox", { name: /Variant/ }) as HTMLInputElement)
.value,
).toBe("email");
});
it("switches to the chosen branch's fields", async () => {
const user = userEvent.setup();
renderWithMantine(
<SchemaForm schema={UNION_SCHEMA} values={{}} onChange={vi.fn()} />,
);
await user.click(screen.getByRole("textbox", { name: /Variant/ }));
await user.click(screen.getByRole("option", { name: "sms" }));
expect(screen.getByRole("textbox", { name: /Phone/ })).toBeTruthy();
expect(screen.queryByRole("textbox", { name: /Address/ })).toBeNull();
});
it("drops the outgoing branch's values and seeds the incoming branch's const", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithMantine(
<SchemaForm
schema={UNION_SCHEMA}
values={{ note: "hi", kind: "email", address: "a@b.c" }}
onChange={onChange}
/>,
);
await user.click(screen.getByRole("textbox", { name: /Variant/ }));
await user.click(screen.getByRole("option", { name: "sms" }));
// `address` belongs to a shape this call is no longer making, so it must
// not ride along invisibly into the submitted arguments.
expect(onChange).toHaveBeenCalledWith({ note: "hi", kind: "sms" });
});
it("renders a const-pinned field read-only", () => {
renderWithMantine(
<SchemaForm
schema={UNION_SCHEMA}
values={{ kind: "email" }}
onChange={vi.fn()}
/>,
);
const kind = screen.getByRole("textbox", {
name: /kind/,
}) as HTMLInputElement;
expect(kind.readOnly).toBe(true);
expect(kind.value).toBe("email");
});
it("renders no picker for a single-branch union but still shows its fields", () => {
const schema: InspectorFormSchema = {
type: "object",
anyOf: [
{
type: "object",
properties: { only: { type: "string", title: "Only" } },
},
],
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
);
expect(screen.getByRole("textbox", { name: /Only/ })).toBeTruthy();
expect(screen.queryByRole("textbox", { name: /Variant/ })).toBeNull();
});
it("renders root allOf fields", () => {
const schema: InspectorFormSchema = {
type: "object",
allOf: [
{
type: "object",
properties: { merged: { type: "string", title: "Merged" } },
},
],
};
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={vi.fn()} />,
);
expect(screen.getByRole("textbox", { name: /Merged/ })).toBeTruthy();
});
it("returns to the first branch when resetKey says the form moved on", async () => {
const user = userEvent.setup();
const { rerender } = renderWithMantine(
<SchemaForm
schema={UNION_SCHEMA}
values={{}}
onChange={vi.fn()}
resetKey="tool-a"
/>,
);
await user.click(screen.getByRole("textbox", { name: /Variant/ }));
await user.click(screen.getByRole("option", { name: "sms" }));
expect(screen.getByRole("textbox", { name: /Phone/ })).toBeTruthy();
rerender(
<SchemaForm
schema={UNION_SCHEMA}
values={{}}
onChange={vi.fn()}
resetKey="tool-b"
/>,
);
expect(screen.getByRole("textbox", { name: /Address/ })).toBeTruthy();
expect(screen.queryByRole("textbox", { name: /Phone/ })).toBeNull();
});
it("clamps a selection the next schema's shorter union cannot hold", async () => {
const user = userEvent.setup();
const { rerender } = renderWithMantine(
<SchemaForm schema={UNION_SCHEMA} values={{}} onChange={vi.fn()} />,
);
await user.click(screen.getByRole("textbox", { name: /Variant/ }));
await user.click(screen.getByRole("option", { name: "sms" }));
// No `resetKey`, so nothing resets the selection: index 1 must be clamped
// rather than reaching past the end of a one-branch union.
const shorter: InspectorFormSchema = {
type: "object",
anyOf: [
{
type: "object",
properties: { solo: { type: "string", title: "Solo" } },
},
],
};
rerender(<SchemaForm schema={shorter} values={{}} onChange={vi.fn()} />);
expect(screen.getByRole("textbox", { name: /Solo/ })).toBeTruthy();
});
});
});

View file

@ -31,6 +31,8 @@ import {
isStringEnum,
normalizeNullableUnion,
} from "@inspector/core/json/nullableUnion.js";
import { resolveRootUnion } from "@inspector/core/json/rootUnion.js";
import { collectSchemaDefaults } from "../../../utils/jsonUtils";
const FieldLabel = Text.withProps({
fw: 500,
@ -45,6 +47,14 @@ const FieldDescription = Text.withProps({
// Indented column for a nested object's sub-fields.
const IndentedStack = Stack.withProps({ gap: "sm", pl: "md" });
// The picker for a root `oneOf`/`anyOf` (#2123). Not clearable: one branch is
// always in effect, so "no branch" is not a state the arguments can be in.
const BranchSelect = Select.withProps({
label: "Variant",
description: "This tool accepts one of several argument shapes.",
allowDeselect: false,
});
const SchemaJsonInput = JsonInput.withProps({
formatOnBlur: true,
autosize: true,
@ -569,8 +579,27 @@ export function SchemaForm({
resetKey,
onValidityChange,
}: SchemaFormProps) {
const properties = schema.properties ?? {};
const requiredFields = schema.required ?? [];
// Composition at the root of the schema, flattened before anything is
// rendered (#2123). `allOf` is folded into `base`; a top-level `oneOf`/`anyOf`
// becomes the branches a picker chooses between. Both are empty for the
// ordinary object schema, where `base` is the schema itself.
const { base, branches } = resolveRootUnion(schema);
// Which alternative the form is currently showing. Held here because it is a
// property of this rendering, not of the arguments: `values` carries what the
// user typed, and nothing in it names a branch.
const [branchIndex, setBranchIndex] = useState(0);
// A form reused for another entity can be handed a shorter union, so the
// index is clamped rather than trusted — `resetKey` resets it below, but a
// caller that omits it (the elicitation panels mount fresh) supplies none.
const activeBranch =
branches.length > 0
? (branches[Math.min(branchIndex, branches.length - 1)] ?? null)
: null;
const effectiveSchema = activeBranch?.schema ?? base;
const properties = effectiveSchema.properties ?? {};
const requiredFields = effectiveSchema.required ?? [];
// The names of fields currently holding unsendable text. Held here rather
// than in each field because only the form sees them all, and only the form
@ -590,7 +619,12 @@ export function SchemaForm({
// *name*, so it must not carry across to another entity's same-named field —
// the same reasoning `resetKey` documents for the number field's draft. Reset
// during render rather than in an effect so no frame paints the wrong shape.
useValueChange(resetKey, () => setEnlargedFields(new Set()));
useValueChange(resetKey, () => {
setEnlargedFields(new Set());
// Which branch is selected belongs to the entity it was chosen for, for the
// same reason enlargement does.
setBranchIndex(0);
});
// Stable so a field's reporting effect subscribes once, not per render. The
// updater returns the previous set unchanged when nothing moved, which is
@ -637,6 +671,35 @@ export function SchemaForm({
onChange({ ...values, [fieldName]: fieldValue });
}
/**
* Switch branches, and move `values` with the form.
*
* The fields the outgoing branch owned are dropped rather than left behind:
* they are no longer rendered, so the user cannot see or clear them, and
* submitting them would send the server arguments belonging to a shape the
* call is not making. Whatever the base contributes is kept — it applies to
* every branch — and the incoming branch's defaults (a discriminator `const`
* among them) are seeded the way the initial ones were.
*/
function handleBranchChange(nextIndex: number) {
const nextBranch = branches[nextIndex];
/* v8 ignore next -- the Select's options are built from `branches` */
if (!nextBranch) return;
setBranchIndex(nextIndex);
const nextProperties = nextBranch.schema.properties ?? {};
const carried: Record<string, unknown> = {};
for (const [name, fieldSchema] of Object.entries(nextProperties)) {
// A field the incoming branch pins to a `const` is not carried: the two
// branches of a discriminated union share the discriminator's *name* and
// disagree about its value, so keeping what the outgoing branch put
// there would leave the arguments claiming the shape they no longer have.
if (values[name] !== undefined && fieldSchema.const === undefined) {
carried[name] = values[name];
}
}
onChange({ ...collectSchemaDefaults(nextBranch.schema), ...carried });
}
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
@ -687,6 +750,26 @@ export function SchemaForm({
);
}
// A string pinned to a single value. Rendered read-only rather than as an
// editable box: `const` admits exactly one value, so anything the user
// could type into it produces a call the schema rejects. This is what a
// discriminated union's `kind`/`by` field is (#2123), and the picker has
// already set it — but the rule is the keyword's, not the union's, so it
// holds for a lone `const` property too.
if (typeof fieldSchema.const === "string") {
return (
<TextInput
key={fieldName}
label={label}
description={description}
withAsterisk={isRequired}
readOnly
disabled={disabled}
value={fieldSchema.const}
/>
);
}
// plain string
if (fieldSchema.type === "string") {
const clearButton = rawValue ? (
@ -878,6 +961,20 @@ export function SchemaForm({
return (
<Stack gap="sm">
{activeBranch && branches.length > 1 && (
<BranchSelect
data={branches.map((branch, index) => ({
value: String(index),
label: branch.label,
}))}
value={String(branches.indexOf(activeBranch))}
disabled={disabled}
onChange={(value) =>
/* v8 ignore next -- Select only ever reports one of its own options */
value === null ? undefined : handleBranchChange(Number(value))
}
/>
)}
{Object.entries(properties).map(([fieldName, fieldSchema]) =>
renderField(fieldName, fieldSchema),
)}

View file

@ -75,6 +75,50 @@ describe("JSON Utils", () => {
},
};
it("coerces a value whose schema lives on a root union branch (#2123)", () => {
const unionTool: Tool = {
name: "union-tool",
inputSchema: {
type: "object",
properties: { note: { type: "string" } },
anyOf: [
{
type: "object",
properties: { count: { type: "number" } },
},
{
type: "object",
properties: { enabled: { type: "boolean" } },
},
],
},
};
// Reading the root's `properties` alone finds no schema for either, so
// both would have been sent as the strings the user typed.
expect(
convertToolParameters(unionTool, {
note: "hi",
count: "42",
enabled: "true",
}),
).toEqual({ note: "hi", count: 42, enabled: true });
});
it("coerces a value whose schema lives on a root allOf branch (#2123)", () => {
const allOfTool: Tool = {
name: "allof-tool",
inputSchema: {
type: "object",
allOf: [
{ type: "object", properties: { count: { type: "number" } } },
],
},
};
expect(convertToolParameters(allOfTool, { count: "7" })).toEqual({
count: 7,
});
});
it("should convert string parameters", () => {
const result = convertToolParameters(tool, {
message: "hello",

View file

@ -0,0 +1,235 @@
import { describe, it, expect } from "vitest";
import { resolveRootUnion } from "@inspector/core/json/rootUnion.js";
const EMAIL = {
type: "object",
properties: {
kind: { type: "string", const: "email" },
address: { type: "string" },
},
required: ["kind", "address"],
};
const SMS = {
type: "object",
properties: {
kind: { type: "string", const: "sms" },
phone: { type: "string" },
},
required: ["kind", "phone"],
};
describe("resolveRootUnion", () => {
it("leaves an ordinary object schema alone", () => {
const schema = {
type: "object" as const,
properties: { message: { type: "string" as const } },
required: ["message"],
};
const { base, branches } = resolveRootUnion(schema);
expect(branches).toEqual([]);
expect(base.properties).toEqual(schema.properties);
expect(base.required).toEqual(["message"]);
});
it("returns a branch per anyOf member, merged with the root", () => {
const { base, branches } = resolveRootUnion({
type: "object",
properties: { note: { type: "string" } },
required: ["note"],
anyOf: [EMAIL, SMS],
});
// The root's own fields render for every branch, so they stay on the base
// and are merged into each branch rather than belonging to one.
expect(Object.keys(base.properties ?? {})).toEqual(["note"]);
expect(branches).toHaveLength(2);
expect(Object.keys(branches[0].schema.properties ?? {})).toEqual([
"note",
"kind",
"address",
]);
expect(branches[0].schema.required).toEqual(["note", "kind", "address"]);
expect(branches[0].ownFields).toEqual(["kind", "address"]);
expect(branches[1].ownFields).toEqual(["kind", "phone"]);
});
it("strips the composition keywords it has absorbed", () => {
const { base, branches } = resolveRootUnion({
type: "object",
anyOf: [EMAIL, SMS],
});
expect(base.anyOf).toBeUndefined();
expect(branches[0].schema.anyOf).toBeUndefined();
});
it("prefers oneOf when a schema carries both", () => {
const { branches } = resolveRootUnion({
type: "object",
oneOf: [EMAIL],
anyOf: [EMAIL, SMS],
});
expect(branches).toHaveLength(1);
});
it("merges allOf branches unconditionally", () => {
const { base, branches } = resolveRootUnion({
type: "object",
properties: { a: { type: "string" } },
required: ["a"],
allOf: [
{
type: "object",
properties: { b: { type: "number" } },
required: ["b"],
},
],
});
expect(branches).toEqual([]);
expect(Object.keys(base.properties ?? {})).toEqual(["a", "b"]);
expect(base.required).toEqual(["a", "b"]);
expect(base.allOf).toBeUndefined();
});
it("merges allOf into every union branch", () => {
const { branches } = resolveRootUnion({
type: "object",
allOf: [{ type: "object", properties: { shared: { type: "string" } } }],
oneOf: [EMAIL, SMS],
});
expect(Object.keys(branches[0].schema.properties ?? {})).toContain(
"shared",
);
});
it("lets a branch's declaration win a name collision with the root", () => {
const { branches } = resolveRootUnion({
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
anyOf: [{ type: "object", properties: { id: { type: "number" } } }, SMS],
});
expect(branches[0].schema.properties?.id).toEqual({ type: "number" });
// `required` unions rather than duplicating.
expect(branches[0].schema.required).toEqual(["id"]);
});
describe("branch labels", () => {
it("uses the branch's own title first", () => {
const { branches } = resolveRootUnion({
type: "object",
anyOf: [{ ...EMAIL, title: "By email" }, SMS],
});
expect(branches[0].label).toBe("By email");
});
it("uses the discriminator property's const when one is named", () => {
const { branches } = resolveRootUnion({
type: "object",
discriminator: { propertyName: "kind" },
oneOf: [EMAIL, SMS],
});
expect(branches.map((branch) => branch.label)).toEqual(["email", "sms"]);
});
it("uses a lone constant-valued property when there is no discriminator", () => {
const { branches } = resolveRootUnion({
type: "object",
anyOf: [EMAIL, SMS],
});
expect(branches.map((branch) => branch.label)).toEqual(["email", "sms"]);
});
it("falls back to a position when a branch has several constants", () => {
const twoConstants = {
type: "object",
properties: {
kind: { const: "a" },
other: { const: "b" },
},
};
const { branches } = resolveRootUnion({
type: "object",
anyOf: [twoConstants, SMS],
});
expect(branches[0].label).toBe("Option 1");
});
it("falls back to a position when a branch names no constant", () => {
const { branches } = resolveRootUnion({
type: "object",
anyOf: [
{ type: "object", properties: { a: { type: "string" } } },
{ type: "object", properties: { b: { type: "string" } } },
],
});
expect(branches.map((branch) => branch.label)).toEqual([
"Option 1",
"Option 2",
]);
});
it("ignores a discriminator naming a property without a usable const", () => {
const { branches } = resolveRootUnion({
type: "object",
discriminator: { propertyName: "missing" },
anyOf: [EMAIL, SMS],
});
expect(branches[0].label).toBe("email");
});
it("labels a numeric const by its value", () => {
const { branches } = resolveRootUnion({
type: "object",
anyOf: [
{ type: "object", properties: { v: { const: 1 } } },
{ type: "object", properties: { v: { const: 2 } } },
],
});
expect(branches.map((branch) => branch.label)).toEqual(["1", "2"]);
});
it("ignores a blank title", () => {
const { branches } = resolveRootUnion({
type: "object",
anyOf: [{ ...EMAIL, title: " " }, SMS],
});
expect(branches[0].label).toBe("email");
});
});
describe("unions it declines to offer", () => {
// Each of these would produce a picker with an option that renders
// nothing, which is the failure this module exists to prevent.
it("declines a union with a member that is not an object", () => {
const { branches } = resolveRootUnion({
type: "object",
anyOf: [EMAIL, "nope" as unknown],
});
expect(branches).toEqual([]);
});
it("declines a union with a fieldless member", () => {
const { branches } = resolveRootUnion({
type: "object",
anyOf: [EMAIL, { type: "null" }],
});
expect(branches).toEqual([]);
});
it("declines an empty union", () => {
expect(resolveRootUnion({ type: "object", anyOf: [] }).branches).toEqual(
[],
);
});
it("ignores an allOf member that is not an object", () => {
const { base } = resolveRootUnion({
type: "object",
properties: { a: { type: "string" } },
allOf: [null as unknown, 3 as unknown],
});
expect(Object.keys(base.properties ?? {})).toEqual(["a"]);
});
});
});

View file

@ -340,3 +340,96 @@ describe("hasMissingRequiredFields", () => {
expect(hasMissingRequiredFields(undescribed, { ghost: null })).toBe(true);
});
});
describe("root composition (#2123)", () => {
const UNION: InspectorFormSchema = {
type: "object",
properties: { note: { type: "string" } },
anyOf: [
{
type: "object",
properties: {
kind: { type: "string", const: "email" },
address: { type: "string" },
},
required: ["kind", "address"],
},
{
type: "object",
properties: {
kind: { type: "string", const: "sms" },
phone: { type: "string" },
},
required: ["kind", "phone"],
},
],
};
it("seeds the first branch's defaults, including its const", () => {
expect(collectSchemaDefaults(UNION)).toEqual({ kind: "email" });
});
it("does not seed fields of branches the form is not showing", () => {
expect(collectSchemaDefaults(UNION)).not.toHaveProperty("phone");
});
it("seeds a const property on an ordinary schema too", () => {
expect(
collectSchemaDefaults({
type: "object",
properties: { version: { type: "string", const: "1" } },
}),
).toEqual({ version: "1" });
});
it("prefers an explicit default over a const", () => {
expect(
collectSchemaDefaults({
type: "object",
properties: {
v: { type: "string", const: "a", default: "b" },
},
}),
).toEqual({ v: "b" });
});
it("collects defaults from a root allOf", () => {
expect(
collectSchemaDefaults({
type: "object",
allOf: [
{
type: "object",
properties: { merged: { type: "string", default: "m" } },
},
],
}),
).toEqual({ merged: "m" });
});
it("blocks submission while no branch is satisfied", () => {
expect(hasMissingRequiredFields(UNION, {})).toBe(true);
expect(hasMissingRequiredFields(UNION, { kind: "email" })).toBe(true);
});
it("allows submission once one branch is satisfied", () => {
expect(hasMissingRequiredFields(UNION, { kind: "sms", phone: "555" })).toBe(
false,
);
});
it("gates on required fields declared only in a root allOf", () => {
const schema: InspectorFormSchema = {
type: "object",
allOf: [
{
type: "object",
properties: { a: { type: "string" } },
required: ["a"],
},
],
};
expect(hasMissingRequiredFields(schema, {})).toBe(true);
expect(hasMissingRequiredFields(schema, { a: "x" })).toBe(false);
});
});

View file

@ -2,6 +2,7 @@ import {
admitsNull,
normalizeNullableUnion,
} from "@inspector/core/json/nullableUnion.js";
import { resolveRootUnion } from "@inspector/core/json/rootUnion.js";
export type JsonValue =
| string
@ -58,6 +59,11 @@ export type InspectorFormSchema = {
const?: JsonValue;
oneOf?: (InspectorFormSchema | JsonSchemaConst)[];
anyOf?: (InspectorFormSchema | JsonSchemaConst)[];
// Root composition the form flattens before rendering (#2123). `allOf` is
// merged into the schema; `oneOf`/`anyOf` at the root become the branches the
// Variant picker chooses between, labelled via `discriminator` when present.
allOf?: InspectorFormSchema[];
discriminator?: { propertyName?: string };
$ref?: string;
};
@ -119,7 +125,13 @@ export function getDataType(value: JsonValue): DataType {
export function collectSchemaDefaults(
schema: InspectorFormSchema,
): Record<string, unknown> {
const properties = schema.properties ?? {};
// Seed from the shape the form actually renders: root `allOf` merged in, and
// for a root union the branch the picker starts on (#2123). Seeding every
// branch would put fields of shapes the call is not making into the
// arguments; seeding none would leave the branch's defaults — its
// discriminator `const` among them — displayed but never submitted.
const { base, branches } = resolveRootUnion(schema);
const properties = (branches[0]?.schema ?? base).properties ?? {};
const result: Record<string, unknown> = {};
for (const [fieldName, rawSchema] of Object.entries(properties)) {
// Collapse a nullable union first, for the same reason `SchemaForm` does:
@ -129,6 +141,11 @@ export function collectSchemaDefaults(
const fieldSchema = normalizeNullableUnion(rawSchema);
if (fieldSchema.default !== undefined) {
result[fieldName] = fieldSchema.default;
} else if (fieldSchema.const !== undefined) {
// `const` is a one-value enumeration, so the only submittable value is
// already known — seeding it spares the user hand-typing a discriminator
// the schema has fixed (#2123), and matches what a `default` would do.
result[fieldName] = fieldSchema.const;
} else if (fieldSchema.type === "object" && fieldSchema.properties) {
const nested = collectSchemaDefaults(fieldSchema);
if (Object.keys(nested).length > 0) {
@ -160,6 +177,25 @@ export function collectSchemaDefaults(
export function hasMissingRequiredFields(
schema: InspectorFormSchema,
values: Record<string, unknown>,
): boolean {
// Root composition first, so a schema keeping its `required` on branches is
// gated at all (#2123). For a union the answer is selection-independent by
// construction: valid arguments must satisfy *some* branch, so the submit is
// blocked only when **every** branch is still missing something. That is
// deliberately weaker than gating on the branch the picker is showing — this
// function is handed `values`, never the selection — but it is sound in the
// direction that matters: it never blocks arguments the schema accepts.
const { base, branches } = resolveRootUnion(schema);
if (branches.length > 0) {
return branches.every((branch) => hasMissingIn(branch.schema, values));
}
return hasMissingIn(base, values);
}
/** {@link hasMissingRequiredFields} against one already-flattened schema. */
function hasMissingIn(
schema: InspectorFormSchema,
values: Record<string, unknown>,
): boolean {
const required = schema.required ?? [];
const properties = schema.properties ?? {};

View file

@ -86,3 +86,39 @@ describe("toolRowKey / findToolByRowKey", () => {
expect(findToolByRowKey(tools, "1:get_weather")).toBeUndefined();
});
});
describe("hasInputFields with root composition (#2123)", () => {
const tool = (inputSchema: Tool["inputSchema"]): Tool => ({
name: "t",
inputSchema,
});
it("sees fields declared on a root union branch", () => {
expect(
hasInputFields(
tool({
type: "object",
anyOf: [
{ type: "object", properties: { a: { type: "string" } } },
{ type: "object", properties: { b: { type: "string" } } },
],
}),
),
).toBe(true);
});
it("sees fields declared on a root allOf", () => {
expect(
hasInputFields(
tool({
type: "object",
allOf: [{ type: "object", properties: { a: { type: "string" } } }],
}),
),
).toBe(true);
});
it("still reports no fields for a bare object schema", () => {
expect(hasInputFields(tool({ type: "object" }))).toBe(false);
});
});

View file

@ -1,4 +1,5 @@
import type { Tool } from "@modelcontextprotocol/client";
import { resolveRootUnion } from "@inspector/core/json/rootUnion.js";
/**
* Returns the display label for an MCP entity that follows the BaseMetadata
@ -14,10 +15,21 @@ export function resolveDisplayLabel(name: string, title?: string): string {
* True when the tool's input schema declares at least one property — used by
* App-flow callers to decide whether to render a form or auto-launch. Kept in
* one place so the definition of "has fields" stays consistent if it ever
* grows to consider `additionalProperties`, `anyOf`, etc.
* grows to consider `additionalProperties` etc.
*
* Root composition is resolved first, since a schema declaring its fields on a
* root `allOf`/`oneOf`/`anyOf` has none of its own (#2123) — an App tool with
* such a schema would otherwise launch with empty arguments rather than asking
* for them.
*/
export function hasInputFields(tool: Tool): boolean {
return Object.keys(tool.inputSchema.properties ?? {}).length > 0;
const { base, branches } = resolveRootUnion(tool.inputSchema);
return (
Object.keys(base.properties ?? {}).length > 0 ||
branches.some(
(branch) => Object.keys(branch.schema.properties ?? {}).length > 0,
)
);
}
/**

View file

@ -1,4 +1,5 @@
import type { Tool } from "@modelcontextprotocol/client";
import { resolveRootUnion } from "./rootUnion.js";
/**
* JSON value type used across the inspector project
@ -132,7 +133,21 @@ export function convertToolParameters(
params: Record<string, string>,
): Record<string, JsonValue> {
const result: Record<string, JsonValue> = {};
const properties = tool.inputSchema?.properties || {};
// A property's schema can live on a root composition branch rather than on
// the root itself (#2123). Reading only the root's `properties` there finds
// no schema for any argument, so every value would be sent as the string the
// user typed — `--tool-arg count=3` reaching the server as `"3"`. The union
// is flattened by merging every branch, because the CLI has no branch
// selection to consult: an argument named by one branch is coerced by that
// branch's schema, and a name two branches type differently keeps the first,
// which is no worse than the untyped passthrough it replaces.
const { base, branches } = resolveRootUnion(tool.inputSchema ?? {});
const properties: Record<string, unknown> = { ...base.properties };
for (const branch of branches) {
for (const name of branch.ownFields) {
properties[name] ??= branch.schema.properties?.[name];
}
}
for (const [key, value] of Object.entries(params)) {
const paramSchema = properties[key] as ParameterSchema | undefined;

236
core/json/rootUnion.ts Normal file
View file

@ -0,0 +1,236 @@
/**
* Flattening of the JSON Schema **composition** keywords that a tool's
* `inputSchema` may carry at its **root** — `allOf`, and a top-level `anyOf` /
* `oneOf` union — into the plain object schema a form renderer can build fields
* from.
*
* Both form builders — the web client's `SchemaForm` and the TUI's
* `schemaToForm` — enumerate the root schema's `properties` and nothing else.
* A schema that keeps its fields on composition branches therefore rendered
* **no controls at all**: not a branch picker, not even the raw-JSON fallback a
* union-typed *property* already gets, so the tool could only ever be called
* with empty arguments (#2123). The 2026-07-28 revision makes such a schema
* explicitly legal — `type: "object"` is required at the root, and "any JSON
* Schema 2020-12 keyword may appear alongside `type`, including composition
* keywords".
*
* Shared for the same reason {@link ./nullableUnion.ts} is: the two clients
* would otherwise grow separate answers to "which schemas can I render", and
* the CLI's argument coercion (`convertToolParameters`) needs the same view of
* where a property's schema lives.
*
* Deliberately **not** a JSON Schema evaluator. It rewrites nothing it cannot
* do faithfully: `not`, and branches that are not object schemas, are left
* alone rather than guessed at (see {@link resolveRootUnion}).
*/
/**
* The keywords this module reads. Both clients' own schema types are
* structurally assignable to this — the shape is the minimum needed to
* *recognize* root composition, not a full JSON Schema model.
*/
export interface RootUnionSchema {
type?: string | string[];
title?: string;
properties?: Record<string, unknown>;
required?: string[];
allOf?: readonly unknown[];
anyOf?: readonly unknown[];
oneOf?: readonly unknown[];
/**
* OpenAPI's discriminator, which real servers emit beside `oneOf` (the issue's
* own repro does). Read only to *label* a branch — never to validate.
*/
discriminator?: { propertyName?: string };
}
/**
* What a resolved schema is: the caller's own type, intersected with the
* keywords this module may have *added* to it.
*
* The intersection is load-bearing rather than decorative. A schema that keeps
* every field on a branch declares no `properties` of its own, so returning the
* caller's `T` unchanged would hand back a type on which `properties` does not
* exist — precisely the property the flattening was performed to produce.
*/
export type ResolvedSchema<T extends RootUnionSchema> = T & RootUnionSchema;
/** One selectable alternative of a root union. */
export interface RootUnionBranch<T extends RootUnionSchema> {
/**
* The base schema merged with this branch: the object schema a renderer
* builds fields from while this alternative is selected.
*/
schema: ResolvedSchema<T>;
/** Human-readable name for the picker — see {@link branchLabel}. */
label: string;
/** Names this branch contributes that the base does not. */
ownFields: string[];
}
/** What {@link resolveRootUnion} decomposes a root schema into. */
export interface ResolvedRootUnion<T extends RootUnionSchema> {
/**
* The schema without its composition keywords: the root's own `properties` /
* `required` with every `allOf` branch merged in. Renderable on its own, and
* what a renderer uses when there is no union.
*/
base: ResolvedSchema<T>;
/**
* One entry per union alternative, or **empty** when the root carries no
* usable union — which is the overwhelmingly common case, so callers can
* treat a non-empty array as "this schema needs a branch picker".
*/
branches: RootUnionBranch<T>[];
}
/** Narrow a composition member to a readable object, or `null` if it isn't one. */
function toBranch(value: unknown): RootUnionSchema | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return null;
}
return value as RootUnionSchema;
}
/**
* Whether a branch contributes anything a form can render.
*
* A `{ type: "null" }` member — the nullable encoding {@link
* ./nullableUnion.ts} owns — and a `$ref`-only or empty branch carry no
* properties, so offering them as alternatives would produce a picker whose
* options render nothing.
*/
function hasFields(branch: RootUnionSchema): boolean {
return (
branch.properties !== undefined && Object.keys(branch.properties).length > 0
);
}
/**
* Merge a composition branch's `properties` and `required` into a base schema.
*
* Both keywords are **conjunctive** where they meet: a value satisfying an
* `allOf` branch satisfies the base *and* the branch, and a value matching a
* union branch must satisfy the root's own constraints too. So properties union
* (branch wins a name collision, being the more specific declaration) and
* `required` unions.
*/
function mergeBranch<T extends RootUnionSchema>(
base: T,
branch: RootUnionSchema,
): ResolvedSchema<T> {
const properties = { ...base.properties, ...branch.properties };
const required = [
...(base.required ?? []),
...(branch.required ?? []).filter(
(name) => !(base.required ?? []).includes(name),
),
];
// One cast, owned here: a branch member is `unknown` on the wire, so its
// property schemas are whatever the server sent however `T` declares them —
// exactly the situation `normalizeNullableUnion`'s hoist documents. Every
// consumer reads a property schema defensively, and the merged object is
// otherwise structurally `T`.
return {
...base,
...(Object.keys(properties).length > 0 ? { properties } : {}),
...(required.length > 0 ? { required } : {}),
} as ResolvedSchema<T>;
}
/** Strip the composition keywords a resolved schema has absorbed. */
function withoutComposition<T extends RootUnionSchema>(
schema: T,
): ResolvedSchema<T> {
const { allOf: _allOf, anyOf: _anyOf, oneOf: _oneOf, ...rest } = schema;
return rest as ResolvedSchema<T>;
}
/**
* A branch's display name, in decreasing order of how deliberate it is:
*
* 1. the branch's own `title` — the author naming it outright;
* 2. the `const` of the discriminator property, when the root names one;
* 3. the `const` of the branch's only constant-valued property, which is what a
* discriminated union looks like without an OpenAPI `discriminator` (the
* `kind: { const: "email" }` shape) — restricted to a *single* candidate, so
* a branch with several constants is not labelled by an arbitrary one;
* 4. a positional fallback.
*/
function branchLabel(
branch: RootUnionSchema,
index: number,
discriminatorProperty: string | undefined,
): string {
if (typeof branch.title === "string" && branch.title.trim() !== "") {
return branch.title;
}
const properties = branch.properties ?? {};
const constOf = (name: string): string | null => {
const property = toBranch(properties[name]) as { const?: unknown } | null;
const value = property?.const;
return typeof value === "string" || typeof value === "number"
? String(value)
: null;
};
if (discriminatorProperty !== undefined) {
const value = constOf(discriminatorProperty);
if (value !== null) return value;
}
const constants = Object.keys(properties)
.map((name) => constOf(name))
.filter((value): value is string => value !== null);
if (constants.length === 1) return constants[0];
return `Option ${index + 1}`;
}
/**
* Decompose a root schema into the object schema a form renders and, when the
* root is a union, the alternatives a picker offers.
*
* `allOf` is merged unconditionally — its branches are conjunctive, so there is
* one correct rendering and no choice to present. A `oneOf` / `anyOf` becomes
* `branches` only when **every** member is an object schema carrying fields:
* a union mixing renderable and unrenderable members would give a picker
* options that show nothing, and the whole point of this module is to stop
* producing a form that cannot express the call. `oneOf` wins when a schema
* carries both, being the stricter of the two.
*/
export function resolveRootUnion<T extends RootUnionSchema>(
schema: T,
): ResolvedRootUnion<T> {
const merged = (schema.allOf ?? []).reduce<ResolvedSchema<T>>(
(acc, member) => {
const branch = toBranch(member);
return branch === null ? acc : mergeBranch(acc, branch);
},
schema as ResolvedSchema<T>,
);
const base = withoutComposition(merged);
const members = schema.oneOf ?? schema.anyOf ?? [];
const branches = members.map(toBranch);
if (
branches.length === 0 ||
branches.some((branch) => branch === null || !hasFields(branch))
) {
return { base, branches: [] };
}
const discriminatorProperty = schema.discriminator?.propertyName;
const baseFields = new Set(Object.keys(base.properties ?? {}));
return {
base,
branches: branches
.filter((branch): branch is RootUnionSchema => branch !== null)
.map((branch, index) => ({
// `base` is already composition-free and `mergeBranch` copies only
// `properties`/`required` off the branch, so the merge stays that way.
schema: mergeBranch(base, branch),
label: branchLabel(branch, index, discriminatorProperty),
ownFields: Object.keys(branch.properties ?? {}).filter(
(name) => !baseFields.has(name),
),
})),
};
}

View file

@ -0,0 +1,67 @@
{
"serverInfo": {
"name": "root-union-schemas-showcase",
"version": "1.0.0"
},
"tools": [{ "preset": "echo" }, { "preset": "get_weather" }],
"rawToolSchemas": {
"echo": {
"inputSchema": {
"type": "object",
"properties": {
"message": { "type": "string" }
},
"required": ["message"],
"anyOf": [
{
"type": "object",
"properties": {
"kind": { "type": "string", "const": "email" },
"address": { "type": "string" }
},
"required": ["kind", "address"]
},
{
"type": "object",
"properties": {
"kind": { "type": "string", "const": "sms" },
"phone": { "type": "string" }
},
"required": ["kind", "phone"]
}
]
}
},
"get_weather": {
"inputSchema": {
"type": "object",
"discriminator": { "propertyName": "by" },
"oneOf": [
{
"type": "object",
"title": "By city",
"properties": {
"by": { "type": "string", "const": "city" },
"city": { "type": "string" }
},
"required": ["by", "city"]
},
{
"type": "object",
"title": "By coordinates",
"properties": {
"by": { "type": "string", "const": "coords" },
"lat": { "type": "number" },
"lon": { "type": "number" }
},
"required": ["by", "lat", "lon"]
}
]
}
}
},
"transport": {
"type": "streamable-http",
"port": 6604
}
}