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

@ -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,
)
);
}
/**