Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
5933162
fix: render tool schemas with root-level anyOf/oneOf composition
cliffhall Aug 26, 2026
67d612a
Merge branch 'v2/main' into v2/fix/2123-root-union-schemas
cliffhall Aug 26, 2026
2782ed7
fix: address Copilot review — const precedence, TUI variant model, de…
cliffhall Aug 26, 2026
a0659b2
Merge remote-tracking branch 'origin/v2/fix/2123-root-union-schemas' …
cliffhall Aug 26, 2026
84cd0c1
fix: address Copilot review round 2 — conjunctive merges, generated-n…
cliffhall Aug 26, 2026
1a271e4
fix: address Copilot review round 3 — conjunctive keywords, unflatten…
cliffhall Aug 26, 2026
d8e9e50
test: cover the non-object property-declaration paths in rootUnion
cliffhall Aug 26, 2026
7e4af5e
fix: address Copilot review round 4 — allOf faithfulness, const null,…
cliffhall Aug 26, 2026
f556f88
fix: address Copilot review round 5 — union member faithfulness, part…
cliffhall Aug 26, 2026
c15e304
fix: address Copilot review round 6 — additionalProperties, annotatio…
cliffhall Aug 26, 2026
85b8e46
fix: address Copilot review rounds 7 and 8
cliffhall Aug 26, 2026
246ee1c
fix: address Copilot review round 9 — canonical constant comparison, …
cliffhall Aug 26, 2026
775fec0
fix: address Copilot review round 10 — structural const match, deep-l…
cliffhall Aug 26, 2026
a69cb58
fix: address Copilot review round 11 — recurse constants into nested …
cliffhall Aug 26, 2026
41be9e7
fix: address Copilot review round 12 — prototype-safe default seeding
cliffhall Aug 26, 2026
df1e39e
fix: address Copilot review round 13 — cross-keyword contradictions, …
cliffhall Aug 26, 2026
5b6d68a
fix: address Copilot review round 14 — allOf under additionalProperti…
cliffhall Aug 26, 2026
0991477
fix: address Copilot review round 15 — unevaluable const siblings, $r…
cliffhall Aug 26, 2026
7792ad7
fix: address Copilot review round 16 — tie-breaking, const-aware gati…
cliffhall Aug 26, 2026
95030cf
fix: address Copilot review round 17 — shared branch narrowing, null …
cliffhall Aug 26, 2026
36cf58f
fix: address Copilot review round 18 — empty-string constants, nested…
cliffhall Aug 26, 2026
44b2ccd
fix: address Copilot review round 19 — empty-string const in the web …
cliffhall Aug 26, 2026
d334710
fix: address Copilot review round 20 — malformed applicators, const-n…
cliffhall Aug 26, 2026
92e485a
fix: address Copilot review round 21 — seed a nested object's own uni…
cliffhall Aug 26, 2026
0be450a
fix: address Copilot review round 22 — merge deep-link args with defa…
cliffhall Aug 26, 2026
4cea4e1
fix: address Copilot review round 23 — inherited constants, branch nu…
cliffhall Aug 26, 2026
54c5e12
fix: address Copilot review round 24 — a discriminator must be required
cliffhall Aug 26, 2026
ab3f8c7
fix: address Copilot review round 25 — names before constants, unrend…
cliffhall Aug 26, 2026
f31589a
fix: address Copilot review round 26 — a false property schema declin…
cliffhall Aug 26, 2026
c8c5442
fix: address Copilot review round 27 — unconstrained null branches, t…
cliffhall Aug 26, 2026
6ac45d9
fix: address Copilot review round 28 — the boolean schema form in a n…
cliffhall Aug 26, 2026
6aa25da
docs: distinguish the partial render from the empty one in the showcase
cliffhall Aug 26, 2026
c913168
Merge branch 'v2/main' into v2/fix/2123-root-union-schemas
cliffhall Aug 26, 2026
67e7a4c
fix: address Copilot review round 30 — seed a read-only const for cal…
cliffhall Aug 26, 2026
c9162d0
fix: address Copilot review round 31 — proto-safe results, set-valued…
cliffhall Aug 26, 2026
00d9905
fix: address Copilot review round 32 — normalize before the type vote…
cliffhall Aug 26, 2026
ffa08d7
fix: address Copilot review round 33 — keep a cleared field cleared a…
cliffhall Aug 26, 2026
ba91db9
fix: address Copilot review round 34 — structured constants, non-requ…
cliffhall Aug 26, 2026
139563f
fix: address Copilot review round 35 — reseed on an in-place schema c…
cliffhall Aug 26, 2026
f78b541
fix: address Copilot review round 36 — re-derive the branch when the …
cliffhall Aug 26, 2026
85b030c
fix: address Copilot review round 37 — stale constants, inherited ans…
cliffhall Aug 26, 2026
ab85667
fix: address Copilot review round 38 — constants must agree, not just…
cliffhall Aug 26, 2026
4865195
fix: address Copilot review round 39 — identify branches by their sch…
cliffhall Aug 26, 2026
8f935ad
fix: address Copilot review round 40 — a required name counts as a field
cliffhall Aug 26, 2026
b0b252a
fix: address Copilot review round 41 — const does not demand a proper…
cliffhall Aug 26, 2026
2e22b5b
Merge branch 'v2/main' into v2/fix/2123-root-union-schemas
cliffhall Aug 26, 2026
e0ecf58
fix: address Copilot review round 42 — let an optional const be opted…
cliffhall Aug 26, 2026
bd114f8
Merge remote-tracking branch 'origin/v2/fix/2123-root-union-schemas' …
cliffhall Aug 26, 2026
f01ec80
Merge branch 'v2/main' into v2/fix/2123-root-union-schemas
cliffhall Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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)).
Comment thread
cliffhall marked this conversation as resolved.
Outdated

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
Expand Down
85 changes: 85 additions & 0 deletions clients/tui/__tests__/schemaToForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] }]);
});
});
});
77 changes: 57 additions & 20 deletions clients/tui/src/utils/schemaToForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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[];
}
Expand Down Expand Up @@ -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 };
}

/**
Expand All @@ -63,15 +77,44 @@ export function schemaToForm(
schema: JsonSchemaObject | null | undefined,
toolName: string,
): FormStructure {
const fields: FormField[] = [];
const title = `Test Tool: ${toolName}`;
if (!schema) {
return { title, sections: [{ title: "Parameters", fields: [] }] };
}

if (!schema || !schema.properties) {
return {
title: `Test Tool: ${toolName}`,
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]]),
);
Comment thread
cliffhall marked this conversation as resolved.
sections.push({
title: branch.label,
fields: buildFields({ properties: ownProperties }),
});
Comment thread
cliffhall marked this conversation as resolved.
Outdated
Comment thread
cliffhall marked this conversation as resolved.
}

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 || [];

Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
};
Loading