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

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 ?? {};