fix: map the new exit code, keep add runnable, distinguish severity by shape

Copilot review round 4.

- Add `SCHEMA_UNPORTABLE` to `codeForExit`. `CliExitCodeError` lets a
  caller omit the envelope, so without the case an envelope-less throw
  reported the generic `error` — the same silent mislabelling the rename
  was meant to prevent, one layer down. Covered via the envelope-less
  construction that exposed it.

- Keep a local `type` (and the preset's `required`) alongside the remote
  `$ref` in the showcase config. The TUI's `schemaToForm` dispatches on
  `type`, so a `$ref`-only property fell through to a string field that
  `add`'s numeric handler then rejected — making the README's "every
  showcase tool is runnable" claim false there. The `remote-ref` finding
  is unaffected, since that rule reads `$ref` independently.

- Restrict `type-union` to well-formed unions: non-empty, unique, and
  all recognized JSON Schema type names. Its message asserts the
  construct is *legal* JSON Schema, which is false for `[]`, `[3]` or
  `["bananas"]`, and the `anyOf` it suggested for those would be invalid
  too. Malformed schemas stay the SDK parser's business.

- Drop `if`/`then`/`else` from the presence-only allowlist. They only
  constrain as a pair, so `{"if": {"const": 1}}` accepts every value and
  was evading `untyped-schema`; the pairing is now checked explicitly.

- Distinguish the web sidebar severities by SHAPE, not just colour — a
  circle for an error, a triangle for a warning — and name the severity
  in the tooltip. Colour alone left a colour-blind reader unable to tell
  them apart.

- Fix the stale "across 2 tools" count in the CLI README's hint sample.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-25 02:37:10 -04:00
commit 2cb23a8b21
7 changed files with 180 additions and 34 deletions

View file

@ -201,7 +201,7 @@ than correlating two.
**Without `--strict` nothing changes except one line.** A `tools/list` whose
schemas have findings prints a single stderr summary — `Schema portability: 1
error, 3 warnings across 2 tools. Re-run with --strict for details.` — and
error, 3 warnings across 3 tools. Re-run with --strict for details.` — and
still exits `0`. A clean list prints nothing at all.
This is deliberately not a JSON Schema validator. A census of 617 public

View file

@ -64,6 +64,18 @@ describe("classifyError", () => {
expect(envelope.url).toBe("https://x.example/mcp");
});
it("derives schema_unportable from the exit code when no envelope is given", () => {
// `CliExitCodeError` lets a caller omit the envelope, in which case the
// machine code comes from `codeForExit`. A new exit code that is missing
// there degrades to the generic `error`, which is exactly the kind of
// silent mislabelling the code map exists to prevent (#1005).
const { exitCode, envelope } = classifyError(
new CliExitCodeError(EXIT_CODES.SCHEMA_UNPORTABLE, "1 finding"),
);
expect(exitCode).toBe(EXIT_CODES.SCHEMA_UNPORTABLE);
expect(envelope.code).toBe("schema_unportable");
});
it("classifies a WWW-Authenticate message as AUTH_REQUIRED without a status", () => {
const { exitCode } = classifyError(
new Error("Dynamic client registration failed: WWW-Authenticate Bearer"),

View file

@ -191,6 +191,8 @@ function codeForExit(exitCode: number): string {
return "unreachable";
case EXIT_CODES.TOOL_ERROR:
return "tool_error";
case EXIT_CODES.SCHEMA_UNPORTABLE:
return "schema_unportable";
default:
return "error";
}

View file

@ -381,5 +381,31 @@ describe("schemaToForm", () => {
type: "string",
});
});
// #1005: the unportable-schemas showcase advertises a remote `$ref` so the
// lint has a `remote-ref` finding to report. A `$ref`-only property falls
// through to a string field here, which the tool's real (numeric) handler
// then rejects — so the fixture keeps a local `type` alongside the ref and
// the tool stays runnable. This pins the half of that contract the TUI
// owns; the wire half is in `raw-tool-schemas.test.ts`.
it("types a property that carries both a remote $ref and a local type", () => {
const form = schemaToForm(
{
properties: {
a: {
type: "number",
$ref: "https://example.com/schemas/number.json",
},
},
},
"refWithLocalType",
);
// `float` is this form builder's numeric field kind — the point is that
// it is NOT the string fallback a `$ref`-only property would get.
expect(form.sections[0]!.fields[0]).toMatchObject({
name: "a",
type: "float",
});
});
});
});

View file

@ -8,7 +8,7 @@ import {
UnstyledButton,
} from "@mantine/core";
import { useMemo } from "react";
import { RiErrorWarningLine } from "react-icons/ri";
import { RiAlertLine, RiErrorWarningLine } from "react-icons/ri";
import type { Tool } from "@modelcontextprotocol/client";
import { lintToolSchemas } from "@inspector/core/json/schemaLint.js";
import { resolveDisplayLabel } from "../../../utils/toolUtils";
@ -87,7 +87,7 @@ export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) {
</ItemBody>
{findings.length > 0 && (
<Tooltip
label={`${findings.length} schema portability finding${findings.length === 1 ? "" : "s"} — select the tool for detail`}
label={`${findings.length} schema portability ${hasError ? "error" : "warning"}${findings.length === 1 ? "" : "s"} — select the tool for detail`}
withArrow
>
<SchemaFlagIcon
@ -102,7 +102,11 @@ export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) {
: "Schema portability warnings"
}
>
<RiErrorWarningLine />
{/* Distinct SHAPES, not just distinct colours: an octagon for an
error and a triangle for a warning, so the severity survives
for a colour-blind reader. The tooltip and the accessible
label name it in words as well. */}
{hasError ? <RiErrorWarningLine /> : <RiAlertLine />}
</SchemaFlagIcon>
</Tooltip>
)}

View file

@ -284,28 +284,32 @@ describe("lintToolSchemas — type-union", () => {
);
});
it("drops non-string members from the suggested branches", () => {
const findings = lintToolSchemas(
tool({
inputSchema: {
type: "object",
properties: { a: { type: ["string", 3] } },
},
}),
);
expect(findings[0]!.suggestion).toContain(
'{"anyOf": [{"type": "string"}]}',
);
it.each([
["an empty array, which matches nothing", []],
["a non-string member", ["string", 3]],
["only non-string members", [3]],
["an unrecognized type name", ["string", "bananas"]],
["a duplicated member", ["string", "string"]],
])("stays quiet on a malformed type array — %s", (_label, type) => {
// The rule's message asserts the construct is *legal* JSON Schema, and the
// `anyOf` it suggests would be invalid for these. Malformed schemas are
// the SDK parser's business, the same way a non-schema node is.
expect(
lintToolSchemas(
tool({ inputSchema: { type: "object", properties: { a: { type } } } }),
),
).toEqual([]);
});
it("falls back to a placeholder when no member is a string", () => {
it.each([
["integer", ["integer", "null"]],
["array", ["array", "null"]],
["object", ["object", "string"]],
])("still fires for a well-formed union containing %s", (_label, type) => {
const findings = lintToolSchemas(
tool({
inputSchema: { type: "object", properties: { a: { type: [3] } } },
}),
tool({ inputSchema: { type: "object", properties: { a: { type } } } }),
);
expect(rules(findings)).toEqual(["type-union"]);
expect(findings[0]!.suggestion).toContain("anyOf");
});
it("raises only the union rule for an array-typed root", () => {
@ -364,6 +368,31 @@ describe("lintToolSchemas — untyped-schema", () => {
expect(paths(findings)).toEqual(["inputSchema.properties.a"]);
});
it.each([
["a lone if", { if: { const: 1 } }],
["a lone then", { then: { type: "string" } }],
["a lone else", { else: { type: "string" } }],
])("flags %s, which asserts nothing on its own", (_label, sub) => {
// `if` has no assertion effect without `then`/`else`, and either of those
// is ignored without an `if` — so these accept every value, and counting
// the keyword's mere presence would let them pass as constrained.
const findings = lintToolSchemas(
tool({ inputSchema: { type: "object", properties: { a: sub } } }),
);
expect(rules(findings)).toEqual(["untyped-schema"]);
});
it.each([
["if + then", { if: { const: 1 }, then: { type: "string" } }],
["if + else", { if: { const: 1 }, else: { type: "string" } }],
])("does not flag %s, which is a real conditional", (_label, sub) => {
expect(
lintToolSchemas(
tool({ inputSchema: { type: "object", properties: { a: sub } } }),
),
).toEqual([]);
});
it.each([
["pattern", { pattern: "^a" }],
["format", { format: "email" }],

View file

@ -256,6 +256,26 @@ describe("rawToolSchemas override (#1005)", () => {
{ tool: "add", rules: ["remote-ref"] },
]);
expect(countFindings(results)).toEqual({ errors: 1, warnings: 3 });
// Every flagged tool in the fixture must stay runnable — the README and
// the PR say so, and a showcase that errors on its own obvious button is
// worse than no showcase. Each is called with the arguments its ADVERTISED
// schema renders, including the properties the override added, since that
// is what a user clicking through the form would send.
const byName = (n: string) => tools.find((t) => t.name === n)!;
await expect(
connected.callTool(byName("get_temp"), { city: "Oslo", units: "C" }),
).resolves.toBeDefined();
await expect(
connected.callTool(byName("echo"), {
message: "hi",
show_ids: null,
opts: {},
}),
).resolves.toBeDefined();
await expect(
connected.callTool(byName("add"), { a: 1, b: 2 }),
).resolves.toBeDefined();
});
it("keeps a structured-content tool callable under an outputSchema override", async () => {