Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
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
26 changes: 26 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,32 @@ 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 rendered under PREFIXED
│ │ # names behind a variant select, because ink-form
│ │ # keys values by field name across the WHOLE form,
│ │ # so two branches' `kind` would otherwise be one
│ │ # field; schemaToForm.decodeFormValues translates
│ │ # back on submit), and convertToolParameters (which
│ │ # branch's schema types a CLI --tool-arg).
│ │ # DECLINES rather than half-reads: a union whose
│ │ # members are not ALL field-carrying objects, and a
│ │ # schema carrying BOTH oneOf and anyOf (independent
│ │ # keywords, satisfied together — picking one drops
│ │ # real constraints). 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
24 changes: 22 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,24 @@ 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**, preceded by a **Variant** select naming which one the call means. The fields in a branch section 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.

The sections are not as independent as they look, which is why the select is not cosmetic: ink-form keeps one value object for the whole form, keyed by field name alone, so two branches both declaring `kind` would be **one** field and the later section's initial value would decide what the earlier one submits. Each branch's fields are therefore rendered under a prefixed name and translated back on submit, where every branch but the chosen one is dropped.

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.

Three things it deliberately does **not** do, each falling back to whatever the root `properties` describe rather than claiming something untrue. 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. A schema carrying **both** `oneOf` and `anyOf` is declined outright: they are independent keywords a value satisfies together, not two spellings of one union, so reading one and dropping the other builds a form that silently omits real constraints — and satisfying both honestly means offering the cross product of their alternatives, which no real schema has yet asked for. 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
179 changes: 178 additions & 1 deletion clients/tui/__tests__/schemaToForm.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest";
import { schemaToForm } from "../src/utils/schemaToForm.js";
import {
decodeFormValues,
schemaToForm,
VARIANT_FIELD,
} from "../src/utils/schemaToForm.js";

describe("schemaToForm", () => {
it("returns an empty Parameters section when there is no schema", () => {
Expand Down Expand Up @@ -408,4 +412,177 @@ 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([
VARIANT_FIELD,
"note",
]);
});

it("names branch fields uniquely, since ink-form scopes by name alone", () => {
// Both branches declare `kind`. Rendered under their real names they
// would be one field, and the later section's initial value would decide
// what the earlier section submits.
const form = schemaToForm(UNION, "union_tool");
expect(form.sections[1]!.fields.map((field) => field.name)).toEqual([
"__b0__kind",
"__b0__address",
]);
expect(form.sections[2]!.fields.map((field) => field.name)).toEqual([
"__b1__kind",
"__b1__count",
]);
});

it("offers a variant select listing the alternatives", () => {
const form = schemaToForm(UNION, "union_tool");
expect(form.sections[0]!.fields[0]).toMatchObject({
name: VARIANT_FIELD,
type: "select",
initialValue: "0",
options: [
{ label: "email", value: "0" },
{ label: "sms", value: "1" },
],
});
});

it("keeps a branch's typed fields typed", () => {
const form = schemaToForm(UNION, "union_tool");
expect(form.sections[2]!.fields[1]).toMatchObject({
name: "__b1__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: "__b0__kind",
initialValue: "email",
});
});

it("prefers a const over a conflicting default", () => {
const form = schemaToForm(
{
type: "object",
properties: { v: { type: "string", const: "a", default: "b" } },
},
"const_tool",
);
expect(form.sections[0]!.fields[0]).toMatchObject({ initialValue: "a" });
});

describe("decodeFormValues", () => {
it("submits the chosen branch's fields under their real names", () => {
expect(
decodeFormValues(UNION, {
[VARIANT_FIELD]: "0",
note: "hi",
__b0__kind: "email",
__b0__address: "a@b.c",
__b1__kind: "sms",
__b1__count: 3,
}),
).toEqual({ note: "hi", kind: "email", address: "a@b.c" });
});

it("drops the branches the call is not making", () => {
expect(
decodeFormValues(UNION, {
[VARIANT_FIELD]: "1",
__b0__kind: "email",
__b0__address: "a@b.c",
__b1__kind: "sms",
__b1__count: 3,
}),
).toEqual({ kind: "sms", count: 3 });
});

it("omits a branch field the user never filled", () => {
expect(
decodeFormValues(UNION, {
[VARIANT_FIELD]: "0",
__b0__kind: "email",
}),
).toEqual({ kind: "email" });
});

it("falls back to the first branch on an unusable selection", () => {
expect(
decodeFormValues(UNION, {
[VARIANT_FIELD]: "nonsense",
__b0__kind: "email",
}),
).toEqual({ kind: "email" });
});

it("returns the values untouched for a schema with no root union", () => {
const values = { message: "hi" };
expect(decodeFormValues({ properties: { message: {} } }, values)).toBe(
values,
);
});
});

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: [] }]);
});
});
});
10 changes: 8 additions & 2 deletions clients/tui/src/components/ToolTestModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { InspectorClient } from "@inspector/core/mcp/index.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import type { Tool, CallToolResult } from "@modelcontextprotocol/client";
import type { JsonValue } from "@inspector/core/mcp/index.js";
import { schemaToForm } from "../utils/schemaToForm.js";
import { decodeFormValues, schemaToForm } from "../utils/schemaToForm.js";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";

interface ToolTestModalProps {
Expand Down Expand Up @@ -114,9 +114,15 @@ export function ToolTestModal({
{ isActive: true },
);

const handleFormSubmit = async (values: Record<string, JsonValue>) => {
const handleFormSubmit = async (rawValues: Record<string, JsonValue>) => {
if (!inspectorClient || !tool) return;

// A root union renders every alternative as its own section, under prefixed
// field names, because ink-form scopes values by name across the whole form
// (#2123). This turns them back into the arguments the server declared:
// the base fields plus the chosen branch's, and nothing from the others.
const values = decodeFormValues(tool.inputSchema, rawValues);

setState("loading");
const startTime = Date.now();

Expand Down
Loading