feat: flag unportable tool schemas in all three clients

Closes #1005

A tool schema can be perfectly legal JSON Schema and still be rejected by
the client the server is meant to run against — Go's `jsonschema` emits a
bare `true` for `interface{}`, which the SDK accepts and Claude Code
refuses with an opaque "Invalid input". The Inspector passed it through
silently.

Adds `core/json/schemaLint.ts`, a portability lint over a tool's
inputSchema/outputSchema, and surfaces one shared verdict everywhere:
the CLI's `--strict` report (exit 6 on an error-severity finding), the
TUI's tool list marker + detail pane, and the web Tools sidebar flag +
detail section.

Deliberately not a JSON Schema validator: a census of 617 public servers
found zero that fail the SDK's own parse, so a conformance check would
report nothing on real servers. Each rule is instead a construct that is
legal JSON Schema and known to be refused or mishandled by a shipping
client — a bare boolean in a schema position, an array-form `type`, a
remote `$ref`, a schema with no validation keyword, a non-object root.

`test-servers/configs/unportable-schemas-http.json` exercises every rule,
via a new `rawToolSchemas` override — the Zod-built presets cannot emit
any of these shapes, which is the same reason a real server only hits
this when its schemas come from another generator.

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 01:18:53 -04:00
commit 2f9e0901c1
9 changed files with 1979 additions and 13 deletions

View file

@ -56,6 +56,27 @@ Components live under `src/components/` in four layers, smallest to largest:
Every screen and element has a `*.stories.tsx` (see [Storybook](#storybook)). Styling follows the Mantine-first rules in [`AGENTS.md`](../../AGENTS.md) — theme variants and component props over CSS, `--inspector-*` tokens over raw colors.
## Tool-schema portability (`#1005`)
The Tools tab flags tools whose advertised `inputSchema` / `outputSchema`
carries a construct that is legal JSON Schema and is refused or mishandled by
real MCP clients — the reported case being Go's `jsonschema` package emitting a
bare `true` for an `interface{}` field, which Claude Code rejects with an
opaque `"Invalid input"`.
Two surfaces, one verdict: the sidebar row (`ToolListItem`) carries a
hover-labelled icon — red when something is refused outright, yellow when it is
merely handled unevenly — and selecting the tool renders a **Schema
portability** section (`SchemaFindingsList`) above the argument form, one block
per finding with its path, the problem, and a concrete fix.
Both read [`core/json/schemaLint.ts`](../../core/json/schemaLint.ts), which is
also what backs the TUI's detail pane and the CLI's `--strict` report — so the
three clients cannot disagree about whether a schema is portable. That module's
header explains why it is a portability lint rather than a JSON Schema
validator. `test-servers/configs/unportable-schemas-http.json` is a server that
exercises every rule.
## Non-component code: `src/lib` vs `src/utils`
Two grab-bag directories, split by one rule: **`utils` = functions that compute; `lib` = things that instantiate, adapt, or touch the environment.** If it does I/O or wraps a subsystem, it's `lib`; if it's a pure transform, it's `utils`.

View file

@ -0,0 +1,86 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import type { Tool } from "@modelcontextprotocol/client";
import { lintToolSchemas } from "@inspector/core/json/schemaLint.js";
import { SchemaFindingsList } from "./SchemaFindingsList";
/** Lint a schema fixture the way the real panels do, so the stories show the
* module's own wording rather than hand-written findings that could drift. */
function findingsFor(tool: Partial<Tool>) {
return lintToolSchemas({ name: "info", ...tool } as Tool);
}
const meta: Meta<typeof SchemaFindingsList> = {
title: "Elements/SchemaFindingsList",
component: SchemaFindingsList,
};
export default meta;
type Story = StoryObj<typeof SchemaFindingsList>;
/** The reported case: Go's `jsonschema` emits `true` for an `interface{}`. */
export const BareTrueProperty: Story = {
args: {
findings: findingsFor({
outputSchema: {
type: "object",
properties: { data: true, topic: { type: "string" } },
},
} as Partial<Tool>),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Schema portability (1)")).toBeVisible();
await expect(canvas.getByText("error")).toBeVisible();
await expect(
canvas.getByText("outputSchema.properties.data"),
).toBeVisible();
},
};
/** Warning-only: an array-form `type`, legal but read unevenly. */
export const TypeUnionWarning: Story = {
args: {
findings: findingsFor({
inputSchema: {
type: "object",
properties: { show_ids: { type: ["null", "boolean"] } },
},
} as Partial<Tool>),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("warning")).toBeVisible();
await expect(canvas.queryByText("error")).toBeNull();
},
};
/** Both severities on one tool, which is what a real problem server looks like. */
export const Mixed: Story = {
args: {
findings: findingsFor({
inputSchema: {
type: "object",
properties: {
show_ids: { type: ["null", "boolean"] },
extra: {},
ref: { $ref: "https://example.com/s.json" },
},
},
outputSchema: { type: "object", properties: { data: true } },
} as Partial<Tool>),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Schema portability (4)")).toBeVisible();
},
};
/** The common case — nothing to say, so the section renders nothing at all. */
export const Clean: Story = {
args: { findings: [] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByTestId("schema-findings")).toBeNull();
},
};

View file

@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { screen } from "@testing-library/react";
import type { Tool } from "@modelcontextprotocol/client";
import { lintToolSchemas } from "@inspector/core/json/schemaLint.js";
import { renderWithMantine } from "../../../test/renderWithMantine";
import { SchemaFindingsList } from "./SchemaFindingsList";
function findingsFor(tool: Partial<Tool>) {
return lintToolSchemas({ name: "info", ...tool } as Tool);
}
describe("SchemaFindingsList", () => {
it("renders nothing when there are no findings", () => {
renderWithMantine(<SchemaFindingsList findings={[]} />);
expect(screen.queryByTestId("schema-findings")).not.toBeInTheDocument();
});
it("shows the path, severity, issue and fix for an error finding", () => {
renderWithMantine(
<SchemaFindingsList
findings={findingsFor({
outputSchema: { type: "object", properties: { data: true } },
} as Partial<Tool>)}
/>,
);
expect(screen.getByText("Schema portability (1)")).toBeInTheDocument();
expect(screen.getByText("error")).toBeInTheDocument();
expect(
screen.getByText("outputSchema.properties.data"),
).toBeInTheDocument();
expect(screen.getByText(/Bare `true`/)).toBeInTheDocument();
expect(screen.getByText(/^Fix: /)).toBeInTheDocument();
});
it("labels a warning finding as such", () => {
renderWithMantine(
<SchemaFindingsList
findings={findingsFor({
inputSchema: {
type: "object",
properties: { a: { type: ["null", "boolean"] } },
},
} as Partial<Tool>)}
/>,
);
expect(screen.getByText("warning")).toBeInTheDocument();
expect(screen.queryByText("error")).not.toBeInTheDocument();
});
it("renders one block per finding", () => {
renderWithMantine(
<SchemaFindingsList
findings={findingsFor({
inputSchema: { type: "object", properties: { a: true, b: true } },
} as Partial<Tool>)}
/>,
);
expect(screen.getByText("Schema portability (2)")).toBeInTheDocument();
expect(screen.getAllByText("error")).toHaveLength(2);
});
});

View file

@ -0,0 +1,103 @@
import { Code, Group, Stack, Text } from "@mantine/core";
import {
describeSchemaPath,
type SchemaFinding,
} from "@inspector/core/json/schemaLint.js";
// Section wrapper: heading + one block per finding.
const FindingsSection = Stack.withProps({
gap: "xs",
});
const FindingsTitle = Text.withProps({
size: "sm",
fw: 600,
});
const FindingsNote = Text.withProps({
size: "xs",
c: "var(--inspector-text-secondary)",
});
// One finding: severity badge + path on the first row, then issue and fix.
const FindingBlock = Stack.withProps({
gap: 2,
});
const FindingHeadRow = Group.withProps({
gap: "xs",
wrap: "nowrap",
align: "center",
});
const FindingText = Text.withProps({
size: "xs",
c: "var(--inspector-text-secondary)",
});
/**
* Severity label.
*
* Deliberately coloured text rather than a filled `Badge`: a filled `yellow`
* badge puts white on `yellow-7`, which at this size is 3.92:1 and fails the
* story's a11y check, and neither `autoContrast` (yellow-7 sits below
* Mantine's default luminance threshold, so it stays white) nor
* `variant="light"` (3.34:1) fixes it. The `--inspector-*` severity tokens are
* the pairings this app already uses against its own surfaces, in both colour
* schemes.
*/
const SeverityLabel = Text.withProps({
size: "xs",
fw: 700,
tt: "uppercase",
});
/**
* Severity → text-colour token. `error` is a construct a shipping MCP client
* refuses outright; `warning` is one handled unevenly.
*/
function severityColor(severity: SchemaFinding["severity"]): string {
return severity === "error"
? "var(--inspector-danger-text)"
: "var(--inspector-warning-text)";
}
export interface SchemaFindingsListProps {
/** Findings for one tool, in walk order. Renders nothing when empty. */
findings: readonly SchemaFinding[];
}
/**
* Tool-schema portability findings for one tool (#1005).
*
* The same verdict the CLI's `--strict` report and the TUI's detail pane show
* — all three read `core/json/schemaLint`, so they cannot disagree about
* whether a schema is portable, only about how much room they have to say so.
*/
export function SchemaFindingsList({ findings }: SchemaFindingsListProps) {
if (findings.length === 0) return null;
return (
<FindingsSection data-testid="schema-findings">
<FindingsTitle>Schema portability ({findings.length})</FindingsTitle>
{findings.map((finding, index) => (
<FindingBlock
key={`${finding.schema}-${finding.path}-${finding.rule}-${index}`}
>
<FindingHeadRow>
<SeverityLabel c={severityColor(finding.severity)}>
{finding.severity}
</SeverityLabel>
<Code>{describeSchemaPath(finding.schema, finding.path)}</Code>
</FindingHeadRow>
<FindingText>{finding.issue}</FindingText>
<FindingText>Fix: {finding.suggestion}</FindingText>
</FindingBlock>
))}
<FindingsNote>
These constructs are legal JSON Schema but are refused or mishandled by
some MCP clients, so a tool can work here and fail there.
</FindingsNote>
</FindingsSection>
);
}

View file

@ -510,6 +510,30 @@ describe("ToolDetailPanel", () => {
});
});
describe("schema portability findings (#1005)", () => {
it("lists a finding with its path when a schema is unportable", () => {
renderWithMantine(
<ToolDetailPanel
{...baseProps}
tool={{
name: "info",
inputSchema: { type: "object", properties: {} },
outputSchema: { type: "object", properties: { data: true } },
}}
/>,
);
expect(screen.getByText("Schema portability (1)")).toBeInTheDocument();
expect(
screen.getByText("outputSchema.properties.data"),
).toBeInTheDocument();
});
it("omits the section for a portable tool", () => {
renderWithMantine(<ToolDetailPanel {...baseProps} tool={simpleTool} />);
expect(screen.queryByTestId("schema-findings")).not.toBeInTheDocument();
});
});
// #2020: Execute used to be gated on `isExecuting` alone — this panel never
// called `hasMissingRequiredFields`, unlike the App and elicitation panels —
// so arguments that could not be sent were executed anyway and simply arrived

View file

@ -11,7 +11,7 @@ import {
Switch,
Text,
} from "@mantine/core";
import { useId, useState } from "react";
import { useId, useMemo, useState } from "react";
import { RiArrowDownSLine, RiArrowRightSLine } from "react-icons/ri";
import type {
ProgressNotification,
@ -24,7 +24,9 @@ import {
toFormSchema,
} from "../../../utils/jsonUtils";
import { getMirroredHeaderParams } from "@inspector/core/json/xMcpHeader.js";
import { lintToolSchemas } from "@inspector/core/json/schemaLint.js";
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
import { SchemaFindingsList } from "../../elements/SchemaFindingsList/SchemaFindingsList";
import { ProgressDisplay } from "../../elements/ProgressDisplay/ProgressDisplay";
import { SchemaForm } from "../SchemaForm/SchemaForm";
@ -219,6 +221,9 @@ export function ToolDetailPanel({
// SEP-2243: args this tool declares as `x-mcp-header` — their values mirror
// into `Mcp-Param-{Name}` headers on a `tools/call` (#1632).
const mirroredParams = getMirroredHeaderParams(tool);
// Memoized on the tool: this panel re-renders on every keystroke in the
// argument form, and the walk depends on nothing that changes in between.
const schemaFindings = useMemo(() => lintToolSchemas(tool), [tool]);
// Descriptions are shown by default (most are short); the chevron lets the
// user hide a long one to keep the form and Execute footer in view. Reset to
@ -338,6 +343,8 @@ export function ToolDetailPanel({
</HeaderParamsSection>
)}
<SchemaFindingsList findings={schemaFindings} />
<SchemaForm
schema={formSchema}
values={formValues}

View file

@ -64,4 +64,48 @@ describe("ToolListItem", () => {
);
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
});
it("shows no schema flag for a clean tool", () => {
renderWithMantine(
<ToolListItem tool={tool} selected={false} onClick={() => {}} />,
);
expect(
screen.queryByLabelText(/Schema portability/),
).not.toBeInTheDocument();
});
it("flags a tool whose schema carries an error-severity finding", () => {
renderWithMantine(
<ToolListItem
tool={{
...tool,
outputSchema: { type: "object", properties: { data: true } },
}}
selected={false}
onClick={() => {}}
/>,
);
expect(
screen.getByLabelText("Schema portability errors"),
).toBeInTheDocument();
});
it("flags a warning-only tool distinctly", () => {
renderWithMantine(
<ToolListItem
tool={{
...tool,
inputSchema: {
type: "object",
properties: { a: { type: ["null", "boolean"] } },
},
}}
selected={false}
onClick={() => {}}
/>,
);
expect(
screen.getByLabelText("Schema portability warnings"),
).toBeInTheDocument();
});
});

View file

@ -1,5 +1,16 @@
import { Group, Image, Stack, Text, UnstyledButton } from "@mantine/core";
import {
Group,
Image,
Stack,
Text,
ThemeIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useMemo } from "react";
import { RiErrorWarningLine } from "react-icons/ri";
import type { Tool } from "@modelcontextprotocol/client";
import { lintToolSchemas } from "@inspector/core/json/schemaLint.js";
import { resolveDisplayLabel } from "../../../utils/toolUtils";
export interface ToolListItemProps {
@ -43,9 +54,20 @@ const ListItemButton = UnstyledButton.withProps({
variant: "listItem",
});
// Schema-portability flag (#1005). Sits in the row rather than only in the
// detail panel so a problem tool is visible without clicking each one — the
// findings are rare enough that a marked row means something.
const SchemaFlagIcon = ThemeIcon.withProps({
size: "xs",
variant: "transparent",
role: "img",
});
export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) {
const { name, title, icons } = tool;
const iconSrc = icons?.[0]?.src;
const findings = useMemo(() => lintToolSchemas(tool), [tool]);
const hasError = findings.some((f) => f.severity === "error");
return (
<ListItemButton
@ -58,6 +80,23 @@ export function ToolListItem({ tool, selected, onClick }: ToolListItemProps) {
<ItemLabel>{resolveDisplayLabel(name, title)}</ItemLabel>
{title && <ItemSubLabel>{name}</ItemSubLabel>}
</ItemBody>
{findings.length > 0 && (
<Tooltip
label={`${findings.length} schema portability finding${findings.length === 1 ? "" : "s"} — select the tool for detail`}
withArrow
>
<SchemaFlagIcon
color={hasError ? "red" : "yellow"}
aria-label={
hasError
? "Schema portability errors"
: "Schema portability warnings"
}
>
<RiErrorWarningLine />
</SchemaFlagIcon>
</Tooltip>
)}
</Row>
</ListItemButton>
);

View file

@ -0,0 +1,463 @@
import { describe, it, expect } from "vitest";
import type { Tool } from "@modelcontextprotocol/client";
import {
countFindings,
describeSchemaPath,
formatSchemaLintReport,
lintToolSchemas,
lintTools,
summarizeFindings,
type SchemaFinding,
type SchemaLintRule,
} from "@inspector/core/json/schemaLint.js";
/**
* Minimal well-formed tool; each test overrides the schema under test.
*
* Overrides are `Record<string, unknown>` rather than `Partial<Tool>` on
* purpose: the SDK types a tool schema as an *object* schema with a literal
* `type: "object"`, and the whole point of this suite is to feed the lint the
* malformed shapes a real server can send — an array-form `type`, a `properties`
* that is not a map, a root that is a bare boolean. `Partial<Tool>` rejects
* every one of them, so the fixture accepts the loose shape and narrows once,
* here, instead of scattering a cast over each case.
*/
function tool(overrides: Record<string, unknown> = {}): Tool {
return {
name: "t",
inputSchema: { type: "object", properties: {} },
...overrides,
} as Tool;
}
/** The rules raised, in order — the shape most assertions here care about. */
function rules(findings: readonly SchemaFinding[]): SchemaLintRule[] {
return findings.map((f) => f.rule);
}
function paths(findings: readonly SchemaFinding[]): string[] {
return findings.map((f) => describeSchemaPath(f.schema, f.path));
}
describe("lintToolSchemas — clean schemas", () => {
it.each([
["an empty object schema", { type: "object", properties: {} }],
[
"a typed property",
{ type: "object", properties: { a: { type: "string" } } },
],
[
"an enum property with no type",
{ type: "object", properties: { a: { enum: ["x", "y"] } } },
],
[
"a const property with no type",
{ type: "object", properties: { a: { const: 3 } } },
],
[
"a local $ref",
{
type: "object",
properties: { a: { $ref: "#/$defs/x" } },
$defs: { x: { type: "string" } },
},
],
[
"additionalProperties: true",
{ type: "object", properties: {}, additionalProperties: true },
],
[
"additionalProperties: false",
{ type: "object", properties: {}, additionalProperties: false },
],
[
"unevaluatedProperties / additionalItems / unevaluatedItems booleans",
{
type: "object",
unevaluatedProperties: false,
additionalItems: true,
unevaluatedItems: false,
},
],
[
"a shape-implying schema with no type",
{
type: "object",
properties: { a: { properties: { b: { type: "string" } } } },
},
],
[
"an anyOf branch set",
{
type: "object",
properties: {
a: { anyOf: [{ type: "string" }, { type: "null" }] },
},
},
],
[
"a typed array with typed items",
{
type: "object",
properties: { a: { type: "array", items: { type: "number" } } },
},
],
])("reports nothing for %s", (_label, inputSchema) => {
expect(lintToolSchemas(tool({ inputSchema }))).toEqual([]);
});
it("reports nothing when a tool declares neither schema", () => {
const bare = { name: "t" } as unknown as Tool;
expect(lintToolSchemas(bare)).toEqual([]);
});
});
describe("lintToolSchemas — boolean-schema", () => {
it("flags the Go interface{} case from the issue", () => {
const findings = lintToolSchemas(
tool({
outputSchema: {
type: "object",
properties: { data: true, topic: { type: "string" } },
},
}),
);
expect(rules(findings)).toEqual(["boolean-schema"]);
expect(paths(findings)).toEqual(["outputSchema.properties.data"]);
expect(findings[0]!.severity).toBe("error");
expect(findings[0]!.suggestion).toContain("additionalProperties");
});
it("flags a bare false, with a different suggestion", () => {
const findings = lintToolSchemas(
tool({
inputSchema: { type: "object", properties: { data: false } },
}),
);
expect(rules(findings)).toEqual(["boolean-schema"]);
expect(findings[0]!.suggestion).toContain("Remove the property");
});
it.each([
[
"items",
{ type: "object", properties: { a: { items: true } } },
"inputSchema.properties.a.items",
],
[
"an anyOf branch",
{ type: "object", properties: { a: { anyOf: [true] } } },
"inputSchema.properties.a.anyOf[0]",
],
[
"a prefixItems entry",
{ type: "object", properties: { a: { prefixItems: [true] } } },
"inputSchema.properties.a.prefixItems[0]",
],
[
"a $defs entry",
{ type: "object", $defs: { x: true }, properties: {} },
"inputSchema.$defs.x",
],
[
"a patternProperties entry",
{ type: "object", patternProperties: { "^a": true }, properties: {} },
'inputSchema.patternProperties["^a"]',
],
[
"a tuple-form items entry",
{ type: "object", properties: { a: { items: [true] } } },
"inputSchema.properties.a.items[0]",
],
[
"propertyNames",
{ type: "object", propertyNames: true, properties: {} },
"inputSchema.propertyNames",
],
["the schema root", true, "inputSchema"],
])("flags a bare boolean under %s", (_label, inputSchema, path) => {
const findings = lintToolSchemas(tool({ inputSchema }));
expect(rules(findings)).toEqual(["boolean-schema"]);
expect(paths(findings)).toEqual([path]);
});
it("quotes a non-identifier property name in the path", () => {
const findings = lintToolSchemas(
tool({
inputSchema: { type: "object", properties: { "odd key": true } },
}),
);
expect(paths(findings)).toEqual(['inputSchema.properties["odd key"]']);
});
});
describe("lintToolSchemas — type-union", () => {
it("flags the nullable array form and suggests the single type", () => {
const findings = lintToolSchemas(
tool({
inputSchema: {
type: "object",
properties: { show_ids: { type: ["null", "boolean"] } },
},
}),
);
expect(rules(findings)).toEqual(["type-union"]);
expect(findings[0]!.severity).toBe("warning");
expect(findings[0]!.suggestion).toContain('"type": "boolean"');
});
it("suggests anyOf when more than one non-null member survives", () => {
const findings = lintToolSchemas(
tool({
inputSchema: {
type: "object",
properties: { a: { type: ["string", "number"] } },
},
}),
);
expect(findings[0]!.suggestion).toContain("anyOf");
});
it("ignores non-string members when picking the suggestion", () => {
const findings = lintToolSchemas(
tool({
inputSchema: {
type: "object",
properties: { a: { type: ["string", 3] } },
},
}),
);
expect(findings[0]!.suggestion).toContain('"type": "string"');
});
it("does not raise non-object-root for an array-typed root", () => {
// The root rule reads a *string* `type`; an array root is the union rule's
// business, and reporting both would name one defect twice.
const findings = lintToolSchemas(
tool({ inputSchema: { type: ["object", "null"] } }),
);
expect(rules(findings)).toEqual(["type-union"]);
});
});
describe("lintToolSchemas — untyped-schema", () => {
it.each([
["an empty schema", {}],
["an annotation-only schema", { description: "anything at all" }],
[
"a title/default-only schema",
{ title: "T", default: 1, examples: [1], deprecated: false },
],
])("flags %s", (_label, sub) => {
const findings = lintToolSchemas(
tool({
inputSchema: { type: "object", properties: { a: sub } },
}),
);
expect(rules(findings)).toEqual(["untyped-schema"]);
expect(paths(findings)).toEqual(["inputSchema.properties.a"]);
});
it("flags an entirely empty root schema", () => {
const findings = lintToolSchemas(tool({ inputSchema: {} }));
expect(rules(findings)).toEqual(["untyped-schema"]);
expect(paths(findings)).toEqual(["inputSchema"]);
});
});
describe("lintToolSchemas — non-object-root", () => {
it("flags a non-object inputSchema root", () => {
const findings = lintToolSchemas(
tool({
inputSchema: { type: "array", items: { type: "string" } },
}),
);
expect(rules(findings)).toEqual(["non-object-root"]);
expect(findings[0]!.severity).toBe("error");
expect(findings[0]!.issue).toContain("inputSchema");
});
it("names outputSchema when that is the offending root", () => {
const findings = lintToolSchemas(
tool({ outputSchema: { type: "string" } }),
);
expect(rules(findings)).toEqual(["non-object-root"]);
expect(findings[0]!.issue).toContain("outputSchema");
});
it("does not flag a nested non-object schema", () => {
const findings = lintToolSchemas(
tool({
inputSchema: {
type: "object",
properties: { a: { type: "array", items: { type: "string" } } },
},
}),
);
expect(findings).toEqual([]);
});
});
describe("lintToolSchemas — remote-ref", () => {
it.each([
["an https ref", "https://example.com/schema.json"],
["a sibling-file ref", "common.json#/$defs/x"],
])("flags %s", (_label, ref) => {
const findings = lintToolSchemas(
tool({
inputSchema: { type: "object", properties: { a: { $ref: ref } } },
}),
);
expect(rules(findings)).toEqual(["remote-ref"]);
expect(findings[0]!.issue).toContain(ref);
});
it("ignores an empty $ref rather than calling it remote", () => {
const findings = lintToolSchemas(
tool({
inputSchema: { type: "object", properties: { a: { $ref: "" } } },
}),
);
expect(findings).toEqual([]);
});
});
describe("lintToolSchemas — walk robustness", () => {
it("skips a value that is not a schema at all", () => {
const findings = lintToolSchemas(
tool({
inputSchema: { type: "object", properties: { a: "nonsense" } },
}),
);
expect(findings).toEqual([]);
});
it("skips keyword values of the wrong shape", () => {
const findings = lintToolSchemas(
tool({
inputSchema: {
type: "object",
properties: "not-a-map",
anyOf: "not-an-array",
},
}),
);
expect(findings).toEqual([]);
});
it("stops at the depth cap instead of recursing forever", () => {
// 200 nested `items`, deeper than MAX_DEPTH (64), each level clean; the
// bare `true` at the bottom sits past the cap and is therefore not
// reported. The assertion is that the walk terminates and stays quiet.
let nested: unknown = true;
for (let i = 0; i < 200; i++) nested = { type: "array", items: nested };
const findings = lintToolSchemas(
tool({
inputSchema: { type: "object", properties: { a: nested } },
}),
);
expect(findings).toEqual([]);
});
it("walks both schemas, inputSchema first", () => {
const findings = lintToolSchemas(
tool({
inputSchema: { type: "object", properties: { a: true } },
outputSchema: { type: "object", properties: { b: true } },
}),
);
expect(paths(findings)).toEqual([
"inputSchema.properties.a",
"outputSchema.properties.b",
]);
});
});
describe("lintTools", () => {
it("drops clean tools and keeps the rest in list order", () => {
const results = lintTools([
tool({ name: "clean" }),
tool({
name: "dirty",
inputSchema: { type: "object", properties: { a: true } },
}),
tool({ name: "also-clean" }),
]);
expect(results.map((r) => r.toolName)).toEqual(["dirty"]);
expect(results[0]!.findings).toHaveLength(1);
});
it("returns an empty list when everything is clean", () => {
expect(lintTools([tool(), tool({ name: "b" })])).toEqual([]);
});
});
describe("reporting helpers", () => {
const results = lintTools([
tool({
name: "info",
inputSchema: {
type: "object",
properties: { show_ids: { type: ["null", "boolean"] } },
},
outputSchema: { type: "object", properties: { data: true } },
}),
]);
it("counts by severity", () => {
expect(countFindings(results)).toEqual({ errors: 1, warnings: 1 });
});
it("counts nothing for an empty result", () => {
expect(countFindings([])).toEqual({ errors: 0, warnings: 0 });
});
it("summarizes with singular nouns at one", () => {
expect(summarizeFindings(results)).toBe("1 error, 1 warning across 1 tool");
});
it("pluralizes above one", () => {
const two = lintTools([
tool({
name: "a",
inputSchema: { type: "object", properties: { x: true, y: true } },
}),
tool({
name: "b",
inputSchema: { type: "object", properties: { x: true } },
}),
]);
expect(summarizeFindings(two)).toBe("3 errors, 0 warnings across 2 tools");
});
it("renders a report with a path, issue and suggestion per finding", () => {
const report = formatSchemaLintReport(results);
expect(report).toContain('Error: tool "info"');
expect(report).toContain('Warning: tool "info"');
expect(report).toContain("Path: outputSchema.properties.data");
expect(report).toContain("Path: inputSchema.properties.show_ids");
expect(report).toContain("Issue: ");
expect(report).toContain("Suggestion: ");
expect(report.trimEnd().endsWith("1 error, 1 warning across 1 tool.")).toBe(
true,
);
});
it("renders a zero summary for an empty result", () => {
expect(formatSchemaLintReport([])).toBe(
"0 errors, 0 warnings across 0 tools.",
);
});
});
describe("describeSchemaPath", () => {
it("names the schema alone at the root", () => {
expect(describeSchemaPath("inputSchema", "")).toBe("inputSchema");
});
it("joins a nested path onto the schema name", () => {
expect(describeSchemaPath("outputSchema", "properties.a")).toBe(
"outputSchema.properties.a",
);
});
});