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

@ -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;
}