Skip to content

feat: flag unportable tool schemas in all three clients - #2121

Merged
cliffhall merged 11 commits into
v2/mainfrom
v2/feat/1005-schema-lint
Aug 25, 2026
Merged

feat: flag unportable tool schemas in all three clients#2121
cliffhall merged 11 commits into
v2/mainfrom
v2/feat/1005-schema-lint

Conversation

@cliffhall

@cliffhallcliffhall commented Aug 25, 2026

Copy link
Copy Markdown
Member

Closes#1005

What

A tool's inputSchema/outputSchema can be perfectly legal JSON Schema and still be rejected outright by the client the server is meant to run against. The reported case is Go's jsonschema package emitting a bare true for an interface{} field: valid Draft 2020-12, accepted by the MCP SDK's own parser, passed through by the Inspector without a word — and refused by Claude Code with an opaque "Invalid input" pointing at ["tools", 7, "outputSchema", "properties", "data"].

This adds a tool-schema portability lintcore/json/schemaLint.ts — and surfaces one shared verdict in all three clients.

The issue was filed against the CLI (and, in January, against v1), but the gap is not CLI-shaped: the Inspector is where a server author looks first, whichever client they happen to be in. So the module is pure and shared, and each client reports it with the room it has.

The rules, and why it is not a validator

Deliberately not a JSON Schema validator, for a reason measured on the issue itself: a census of 617 public servers (14,804 tool schemas) found 0 that fail the SDK's own ListToolsResultSchema.safeParse. A conformance check would report nothing on essentially every real server. What bites is the narrower subset each consumer accepts, so every rule here is a construct that is legal JSON Schema and known to be refused or quietly mishandled by a shipping client.

RuleSeverityFires on
boolean-schemaerrorA bare true/false where a schema object is expected — the Go interface{} case
type-unionwarningThe array form, "type": ["null","boolean"]
remote-refwarningA $ref pointing outside the document
untyped-schemawarningA schema carrying no constraining keyword at all — the object-literal spelling of true

Five precision decisions worth reviewing:

  • additionalProperties: true is not flagged. A boolean is the idiomatic spelling under additionalProperties / unevaluatedProperties / additionalItems / unevaluatedItems, and it is the issue's own suggested fix. The rule fires only in positions where a boolean is legal-but-unportable.
  • There is no "inputSchema must be an object" rule, even though MCP requires one. The SDK types inputSchema with type: literal("object"), so such a tool fails ListToolsResultSchema and salvageListItems drops it before any client sees it — verified against a live server, where listAllTools() returns []. A rule that cannot fire is worse than none, because the docs then claim a check the tool does not perform. The condition is already reported through the malformed-items surface.
  • untyped-schema uses an allowlist of constraining keywords, not a denylist of annotations. JSON Schema ignores keywords it does not recognize, so {"vendorHint": true} accepts every value; a denylist would pass it as constrained merely because the keyword is unfamiliar. $defs-only schemas are caught for the same reason. Conversely {"properties": {…}} with no typedoes constrain its input, so it is left alone — as is {} under not, where an always-accepting subschema means always-reject.
  • Every suggestion says whether it preserves the contract.false in a property position forbids that property, so the fix is {"not": {}} and not "delete the entry" (which would permit it under the default additionalProperties). An array-form type becomes anyOf branches, not "drop it from required" (absent ≠ null). Where a replacement genuinely narrows — true, and the untyped case — the text says so rather than implying equivalence.
  • Each rule stays quiet on schemas that are merely malformed.type-union fires only on a non-empty, unique array of recognized type names, because its message asserts the construct is legal JSON Schema — which would be false for [], [3], or ["bananas"], and the anyOf it suggests for those would be invalid too. Same principle as the walk skipping a node that is neither an object nor a boolean: malformed is the SDK parser's business.

Surfaces

CLI--strict (with --method tools/list) prints the report from the issue on stderr and exits 6 if any finding is error-severity:

Error: tool "get_temp"
Path: outputSchema.properties.data
Issue: Bare `true` used where a schema object is expected.
Suggestion: Declare what the value actually is — e.g. `{"type": "object", "additionalProperties": true}` for a free-form object. That narrows the schema deliberately; `true` accepts any JSON value at all.
Warning: tool "echo"
Path: inputSchema.properties.show_ids
Issue: `type` is an array (["null","boolean"]). …
Suggestion: Split it into `anyOf` branches, each with a single `type` — `{"anyOf": [{"type": "null"}, {"type": "boolean"}]}`. (Making the property optional instead is a different contract: absent is not the same as `null`.)
1 error, 3 warnings across 3 tools.

Only errors fail the run — a --strict that failed on warnings would be unusable as a CI gate against servers that are in fact fine. Under --format json the findings ride the same envelope ({"result":…,"schemaFindings":[…]}) rather than as a second document. --strict is rejected alongside --app-info, because that path returns NDJSON straight from runMethod and never reaches the lint — accepting the pair would hand a CI caller a gate that can never fail. Without --strict, nothing changes but one line: a summary on stderr (Schema portability: 1 error, 3 warnings across 3 tools. Re-run with --strict for details.), still exit 0, and nothing at all on a clean list.

TUI — the tools list flags an offending tool (! red / ? yellow) and the detail pane lists each finding under Schema Portability. Rendered frame (the terminal can't be screenshotted headlessly, so this is ToolsTab's real output through ink-testing-library, colour stripped):

 Tools (4) │ get_temp [Enter to Test]
│
▶ get_temp ! │ Get the current temperature for a city
echo ? │
add ? │ Schema Portability (1):
get_weather │
│ ! outputSchema.properties.data
│ Bare `true` used where a schema object is expected.
│ Fix: Declare what the value actually is — e.g. `{"type": "object",
│ "additionalProperties": true}` for a free-form object. That narrows the schema
│ deliberately; `true` accepts any JSON value at all.
│
│ Input Schema:
│
│ {
│ "type": "object",
│ …

Web — the Tools sidebar row carries a severity icon (hover-labelled, and named in the accessible label); selecting the tool renders a Schema portability section above the argument form.

Two accessibility details in there are fixes rather than preferences:

  • Severity is carried by shape, not only colour — a circle for an error, a triangle for a warning — so a colour-blind reader can still tell them apart in the sidebar. The TUI does the same with ! vs ?.
  • Both web surfaces colour from the app's --inspector-danger-text / --inspector-warning-text tokens rather than Mantine's color="yellow". A filled yellow badge puts white on yellow-7, which at that size is 3.92:1 and fails the story's a11y check; neither autoContrast (yellow-7 sits below Mantine's default luminance threshold, so the label stays white) nor variant="light" (3.34:1) rescues it.

Web screenshots

Tools list — get_temp flagged red (an error-severity finding), echo and add yellow (warnings only), get_weather unmarked:

Tools list with schema-portability flags

Detail panel — one block per finding, above the argument form:

Tool detail panel showing the Schema portability section

A warning-only tool (echo, two warnings) — same section, different severity labels and different findings, and nothing here fails --strict:

Tool detail panel for a warning-only tool, showing two WARNING findings

The sidebar flag's tooltip carries the severity breakdown rather than the bare total, so a tool with a mix is not announced as all-errors:

Sidebar tooltip reading Schema portability: 2 warnings

A clean tool (get_weather) — the section is absent entirely, which is the case for essentially every real server:

Tool detail panel for a portable tool, with no findings section

Try it

test-servers/configs/unportable-schemas-http.json exercises every rule, with get_weather left clean for contrast. It needs a new rawToolSchemas config override, because the Zod-built presets cannot emit any of these constructs — which is the same reason a real server only hits this when its schemas come from another generator.

npm run test-servers:build --prefix clients/web
node test-servers/build/server-composable.js --config test-servers/configs/unportable-schemas-http.json
mcp-inspector --cli http://127.0.0.1:6603/mcp --method tools/list --strict # exits 6

Every tool in that fixture is still runnable, and keeping that true took two deliberate choices, both now pinned by tests:

  • A conforming client validates a result against the advertised output schema, so an outputSchema override on a preset returning no structuredContent makes every call to it fail. The flagship bare true therefore rides get_temp (which does return structured content) rather than echo.
  • The remote-ref demo keeps a local type alongside the $ref. The TUI's schemaToForm dispatches on type, so a $ref-only property falls through to a string field that add's numeric handler rejects — the finding would have been demonstrated at the cost of the tool.

Testing

  • core/json/schemaLint.ts — 78 cases: every rule, every walk position (properties, items in both forms, prefixItems, $defs, patternProperties, dependentSchemas, draft-07 dependencies in both its schema and property-list forms, combinator branches), path quoting, the depth cap, the non-schema/wrong-shape skips, and the report/summary formatters.
  • rawToolSchemas — a new integration test boots the composable server over a real transport and asserts the raw document actually reaches the wire, that an unnamed tool keeps its Zod schema, that the override survives duplication, both halves of the outputSchema caveat above, and that a non-object input root is dropped by salvage before any client sees it (the fact the missing root rule rests on).
  • CLI — 19 cases over the handler and the emitResult wiring: exit 6 on error, exit 0 on warnings-only, the --format json envelope, both --strict argument rejections driven through the real runCli, and isError (exit 5) winning over the schema verdict.
  • Web — SchemaFindingsList unit tests + 4 Storybook play stories; ToolListItem and ToolDetailPanel cases for the flag and the section.
  • TUI — list-marker and detail-pane cases, plus schemaMarker directly.
  • npm run ci green.

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>
@cliffhallcliffhall added the v2 Issues and PRs for v2 label Aug 25, 2026
@cliffhall
cliffhall requested a balanced review from CopilotAugust 25, 2026 05:19

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds shared tool-schema portability linting across Web, TUI, and CLI clients.

Changes:

  • Introduces shared schema lint rules and reporting helpers.
  • Adds CLI --strict, Web indicators, and TUI findings.
  • Adds fixtures, tests, and documentation.

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 8 comments.

Show a summary per file
FileDescription
core/json/schemaLint.tsImplements shared schema linting.
clients/cli/src/cli.tsAdds --strict argument handling.
clients/cli/src/error-handler.tsAdds schema-invalid exit code.
clients/cli/src/handlers/emit-result.tsEmits findings and strict failures.
clients/cli/src/handlers/method-types.tsAdds strict method option.
clients/cli/src/handlers/schema-lint-report.tsFormats CLI lint reports.
clients/cli/__tests__/schema-lint-report.test.tsTests CLI reporting behavior.
clients/cli/README.mdDocuments strict mode.
clients/tui/src/components/ToolsTab.tsxDisplays TUI markers and findings.
clients/tui/__tests__/ToolsTab.test.tsxTests TUI schema indicators.
clients/tui/README.mdDocuments TUI behavior.
clients/web/src/components/elements/SchemaFindingsList/SchemaFindingsList.tsxRenders Web findings.
clients/web/src/components/elements/SchemaFindingsList/SchemaFindingsList.test.tsxTests findings rendering.
clients/web/src/components/elements/SchemaFindingsList/SchemaFindingsList.stories.tsxAdds Storybook scenarios.
clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsxAdds findings to tool details.
clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsxTests detail findings.
clients/web/src/components/groups/ToolListItem/ToolListItem.tsxAdds sidebar warning icon.
clients/web/src/components/groups/ToolListItem/ToolListItem.test.tsxTests sidebar indicators.
clients/web/src/test/core/schemaLint.test.tsTests shared lint rules.
clients/web/README.mdDocuments Web integration.
test-servers/src/load-config.tsDefines raw schema configuration.
test-servers/src/resolve-config.tsPropagates raw schema settings.
test-servers/src/composable-test-server.tsOverrides advertised tool schemas.
test-servers/configs/unportable-schemas-http.jsonAdds demonstration fixture.
README.mdDocuments the feature and fixture.
AGENTS.mdRecords the shared architecture.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment threadcore/json/schemaLint.ts Outdated
Comment threadcore/json/schemaLint.ts Outdated
Comment threadcore/json/schemaLint.ts
Comment threadclients/web/src/test/core/schemaLint.test.ts Outdated
Comment threadtest-servers/src/composable-test-server.ts
Comment threadclients/web/src/components/groups/ToolListItem/ToolListItem.tsx Outdated
Comment threadcore/json/schemaLint.ts Outdated
Comment threadclients/cli/src/cli.ts Outdated
- Scope `non-object-root` to `inputSchema`. The pinned SDK types
`inputSchema` with `type: literal("object")` but `outputSchema` as a
bare `looseObject`, so a `{"type":"string"}` output is conforming; the
rule was claiming a requirement MCP does not make, at error severity.
- Decide `untyped-schema` from an allowlist of constraining keywords
rather than a denylist of annotations. JSON Schema ignores keywords it
does not recognize, so `{"vendorHint": true}` accepts every value and a
denylist passed it as constrained. `$defs`-only schemas now flag too.
- Walk `dependentSchemas` and draft-07 `dependencies`, with a test
pinning that the property-name-array form is not a schema position.
- Suggest `anyOf` branches for an array-form `type`, never "drop it from
`required`" — accepting `null` and permitting absence are independent
contracts, so the old suggestion silently changed the schema's meaning.
- Reject `--strict` alongside `--app-info`. That path returns NDJSON from
`runMethod` and never reaches the lint, so the flag was a no-op gate
that could never fail.
- Colour the web sidebar flag from the `--inspector-*` severity tokens,
matching the detail panel and avoiding the default yellow that failed
the a11y check.
- Add a live integration test for `rawToolSchemas`. It caught that an
`outputSchema` override on a preset returning no structured content
makes every call to that tool fail client-side validation — the
showcase config did exactly that, so the flagged tool could not be
run. Moved the bare `true` onto `get_temp`, documented the caveat, and
pinned both halves.
- Drop the double cast from the lint test fixture.
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>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Review round 1 — all 8 comments addressed (c3895657)

Every one was valid; three were real defects rather than polish. Mirroring here because the inline threads go outdated once the fix is pushed.

#FindingOutcome
1non-object-root also fired on outputSchemaFixed — scoped to inputSchema
2untyped-schema used a denylist, so unknown keywords counted as validationFixed — allowlist of constraining keywords
3Walk missed dependentSchemas / dependenciesFixed — both walked, array form pinned
4Double cast in the lint testFixed — fixture narrows once
5No live test for rawToolSchemasFixed — integration test added, and it caught a real bug
6Sidebar icon used the default yellowFixed — same severity tokens as the detail panel
7type-union suggestion changed the schema's meaningFixed — suggests anyOf
8--strict --app-info was a silent no-opFixed — combination rejected

The three worth reading

#1 — the SDK settles it.ToolSchema types inputSchema as z.object({ type: z.literal("object"), … }) but outputSchema as z.looseObject({ $schema: z.string().optional() }), with no type constraint. So a {"type":"string"} output schema is conforming, and the rule was asserting a requirement MCP does not make — at error severity, which would have exited --strict with 6 against a valid server. Now scoped to inputSchema, with the asymmetry cited in the doc comment so it isn't "tidied up" later.

#7 — the suggestion was wrong, not merely suboptimal. For a required type: ["null","boolean"] field, "use boolean and drop it from required" both stops accepting null and starts accepting omission. Accepting an explicit null and permitting absence are independent contracts. The suggestion is now always anyOf branches rendered from the real members — {"anyOf": [{"type": "null"}, {"type": "boolean"}]} — which is equivalent and which this lint already treats as portable. Optionality is mentioned only as an explicitly different contract, and a test asserts the old phrasing can't come back.

#5 — the test earned its keep immediately. Asking for live coverage of rawToolSchemas surfaced something no unit test could see: a conforming client validates a tool result against the advertised output schema, so putting an outputSchema override on a preset whose handler returns no structuredContent makes every call to that tool fail. The showcase config did exactly that to echo, so pressing Execute on the flagged tool would have errored — a fixture that breaks on its own obvious button. The bare true now rides get_temp (which does return structured content), the caveat is documented on ServerConfig.rawToolSchemas and in the README, and both halves are pinned by tests.

One judgement call flagged rather than silently taken, on #2: format is in the allowlist even though 2020-12's default vocabulary makes it an annotation rather than an assertion. Strictly {"format":"email"} does accept any value — but a schema declaring one is plainly an attempt to constrain, and reporting it as "accepts any value" would be noise rather than something an author can act on. Called out in the comment so the deviation is deliberate.

Screenshots and the TUI frame in the PR body are re-captured against the reshaped fixture. npm run ci green.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated 2 comments.

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

clients/cli/src/handlers/emit-result.ts:68

  • The stable machine-readable code says the schema is invalid, but this feature deliberately reports schemas that are legal JSON Schema and merely unportable across clients. Automation consuming the envelope can therefore misclassify a portability failure as validation failure. Use a portability-specific code such as schema_unportable consistently.
 { code: "schema_invalid" },

core/json/schemaLint.ts:371

  • {} accepts every JSON value, but the proposed “genuinely free-form” replacement accepts only objects. Applying this fix would reject strings, numbers, booleans, arrays, and null that the original schema allows. Use single-type anyOf branches to preserve an unconstrained JSON value.
 "Schema carries no validation keyword at all, so it accepts any value — the object-literal spelling of a bare `true`.",
'Give it an explicit `type`. For a genuinely free-form value use `{"type": "object", "additionalProperties": true}`.',

Comment threadcore/json/schemaLint.ts Outdated
Comment threadcore/json/schemaLint.ts Outdated
Copilot review round 2.
- Remove `non-object-root` entirely. It could never fire: the SDK types
`inputSchema` with `type: literal("object")`, so a tool with any other
root fails `ListToolsResultSchema` and `salvageListItems` drops it
before any client sees it. Verified against a live server advertising
`inputSchema: {type: "array"}` — `listAllTools()` returns []. A rule
that cannot fire is worse than none, because the docs then claim a
check the tool does not perform; the condition is already reported
through the malformed-items surface. Pinned by an integration test so
a change in that behavior is a signal rather than a silent gap.
- Fix the bare-`false` suggestion. `properties: {a: false}` forbids the
property; deleting the entry *permits* it with any value under the
default `additionalProperties`, so the advice inverted the schema it
replaced. Now recommends `{"not": {}}` and says why deletion differs.
- Exempt `not`-parented schemas from `untyped-schema`, so the `{"not":
{}}` above does not trip this module's own rule — under negation an
always-accepting subschema means always-reject, making "accepts any
value" the exact opposite of what it does. Scoped to that position,
not inherited by its subtree.
- Say plainly, in the `true` and untyped suggestions, that they narrow
the schema deliberately rather than implying an equivalent rewrite.
`true` has no portable object-form equivalent — `{}` is the very shape
this lint flags.
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>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Review round 2 — both comments addressed (cac97c14)

Both were valid, and one of them deleted a rule rather than fixing it. Mirroring here because the inline threads go outdated on push.

non-object-root was unreachable — rule removed

The claim was strong enough to verify rather than accept on the reading, so I probed it: a live composable server advertising inputSchema: {type: "array", items: {type: "string"}}, connected over a real transport.

PROBE_TOOLS=[]

The tool never arrives. ToolSchema types inputSchema with type: literal("object"), so the entry fails safeParse inside salvageListItems and lands in malformed rather than valid. All three clients read the salvaged list, so no surface could ever hand such a tool to the lint.

That makes it worse than a missing rule: the README, AGENTS.md, and the PR body were all describing a check that could not run. Removed — gone from SchemaLintRule, lintSchema is now just the walk, and each doc site now explains why there is no such rule. Nothing is lost in coverage; the condition is already surfaced by the malformed-items path, which is where a dropped tool belongs. I chose that over detecting it in the raw/salvage path, which would mean a second raw tools/list in the style of refreshExcludedTools — a lot of machinery to re-report something the user is already told.

Two guards keep it honest: the live behaviour is pinned in raw-tool-schemas.test.ts with a comment saying that if it ever starts returning the tool, the rule is worth adding back and that test is the signal; and the unit suite asserts the module stays quiet on non-object roots rather than silently regrowing the check.

The false suggestion inverted the schema

properties: {a: false} forbids the property; deleting the entry permits it with any value under the default additionalProperties. The advice did the opposite of the schema it replaced. Now {"not": {}}, with the difference stated.

That needed one more change to avoid self-defeat: {"not": {}} would itself have tripped untyped-schema, since the walk descends into not and finds an empty schema. So the rule no longer fires when the parent keyword is not — under negation an always-accepting subschema means always-reject, so "accepts any value" would be exactly backwards. Scoped to that position only, not inherited by the subtree; both halves tested.

This is the same class of defect as the type-union suggestion in round 1, which I fixed only where it was pointed at instead of sweeping for the pattern. So this round I went through all of them: every suggestion now either preserves the contract or says plainly that it narrows deliberately. true in particular has no portable object-form equivalent — {} is the very shape this lint flags — so claiming otherwise would have been the wrong advice.

npm run ci green.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated 3 comments.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

README.md:308

  • The “Try it” link points back to the current Unportable tool schemas heading; there is no Try it section in this README. This makes the promised caveat link misleading instead of navigating to a target. Refer directly to the caveat below.
returns structured content) rather than `echo` — see the caveat under
[Try it](#unportable-tool-schemas) below.

Comment threadtest-servers/src/resolve-config.ts
throw new CliExitCodeError(
EXIT_CODES.SCHEMA_INVALID,
`${errors} tool schema error${errors === 1 ? "" : "s"} found (--strict).`,
{ code: "schema_invalid" },

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — and this one is squarely a self-inflicted contradiction: the module's own header argues at length that these schemas are valid JSON Schema and are merely refused by some clients, and then the machine-readable code said schema_invalid.

Renamed throughout, while it is still pre-ship and nothing depends on it:

  • EXIT_CODES.SCHEMA_INVALIDSCHEMA_UNPORTABLE
  • envelope code: "schema_invalid""schema_unportable"
  • the message now reads N tool schema portability error(s) found (--strict) rather than N tool schema error(s), so the human line does not misclassify it either
  • the exit-code map's doc comment and the CLI README's exit-code table both say why 6 is "unportable" and not "invalid"

The test now asserts the new code with a comment naming the reason, so a future rename back would have to argue with it rather than just pass.

Comment threadclients/cli/README.md Outdated
…docs
Copilot review round 3.
- Rename the `--strict` failure from "invalid" to "unportable"
(`EXIT_CODES.SCHEMA_UNPORTABLE`, envelope `schema_unportable`, and the
message text). The module argues at length that these schemas ARE
valid JSON Schema and are merely refused by some clients, so a
machine-readable code of `schema_invalid` told automated callers the
opposite. Done now, while nothing depends on it.
- Add a fixture-path integration case. Every other test built a
`ServerConfig` by hand, so `resolveConfig`'s `rawToolSchemas`
forwarding was uncovered and `unportable-schemas-http.json` was loaded
by nothing — the shipped fixture could have lost its overrides with
the suite still green. It now goes through loadConfig/resolveConfig
and asserts the exact per-tool verdict the README claims.
- Refresh the CLI README's sample output, which still showed the
pre-round-2 wording and fixture, and so dropped the contract warning
that rewording existed to add. Captured from a real run rather than
written by hand, plus a paragraph making the suggestion pattern
explicit.
- Drop the self-referential "[Try it](#unportable-tool-schemas)" link in
the root README, which pointed back at its own section.
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>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Review round 3 — all 4 addressed (a3d864ed)

Three inline plus the suppressed one, all valid. Mirroring here since the inline threads go outdated on push.

schema_invalid misnamed the failure

The sharpest of the three, because it was a contradiction with the PR's own premise: the module argues at length that these schemas are valid JSON Schema and are merely refused by some clients, and then the machine-readable code said schema_invalid. An automated caller branching on it would have been told the wrong thing.

Renamed while nothing depends on it yet — EXIT_CODES.SCHEMA_UNPORTABLE, envelope schema_unportable, and the message now reads N tool schema portability error(s) found (--strict). The exit-code map's comment and the README's exit-code table both say why 6 is "unportable" and not "invalid", and the test asserts the new code with the reason attached.

The fixture was covered by nothing

Every case in the new integration suite built a ServerConfig by hand, so resolveConfig's rawToolSchemas forwarding was an uncovered line and unportable-schemas-http.json was loaded by no test at all — the shipped fixture could have silently lost its overrides with the whole suite still green.

Added a fixture-path case on the duplicate-tool-names.test.ts model: resolveConfig(loadConfig(configPath)), then boot from the resolved config and assert on the wire. It pins the exact per-tool verdict the README and the screenshots claim, not just "some findings exist":

{ tool: "get_temp", rules: ["boolean-schema"] },
{ tool: "echo", rules: ["type-union", "untyped-schema"] },
{ tool: "add", rules: ["remote-ref"] },

plus { errors: 1, warnings: 3 }. So docs-vs-fixture drift now fails a test.

The CLI README sample was stale

Left over from before round 2 reworded the suggestion, so it showed output the CLI no longer produces — and dropped exactly the contract warning that rewording existed to add. The tool name and counts were stale too, still describing the pre-round-2 fixture. Replaced with real output captured from a run against the showcase server rather than hand-written, so it matches verbatim, plus a short paragraph making the suggestion pattern explicit instead of leaving it to be inferred from one example.

Suppressed comment

Also fixed: the root README's [Try it](#unportable-tool-schemas) link pointed back at its own section. Now refers to the caveat directly.

npm run ci green.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated 2 comments.

Suppressed comments (4)

Previously missed (3) — in code that hasn't changed since the last review.

core/json/schemaLint.ts:179

  • if, then, and else do not constrain a schema independently: if has no assertion effect without then/else, and then/else are ignored without if. Consequently schemas such as { "if": { "const": 1 } } and { "then": { "type": "string" } } still accept every value but evade untyped-schema. Remove these from the presence-only allowlist and count them only when if is paired with then or else.
 "if",
"then",
"else",

core/json/schemaLint.ts:338

  • This branch treats every array-valued type as a legal union, including malformed values such as [], [3], duplicates, or unknown type names. The emitted issue then incorrectly says the schema is legal and may suggest an invalid or contract-changing replacement (the tests currently expect non-string members to be silently dropped). Restrict this portability rule to non-empty, unique arrays of recognized JSON Schema type names; malformed arrays should either be skipped here or reported by a separate validation rule.
 if (Array.isArray(type)) {
const members = type.filter((t): t is string => typeof t === "string");

clients/web/src/components/groups/ToolListItem/ToolListItem.tsx:98

  • The two severities are visually distinguished only by color: both branches render the same warning icon, and the visible tooltip text does not say “error” or “warning.” Color-blind users therefore cannot tell the severity from this row. Use distinct icon shapes/text (for example ! versus ?) or another always-visible non-color cue, while retaining the accessible label.
 c={
hasError
? "var(--inspector-danger-text)"
: "var(--inspector-warning-text)"
}

clients/cli/README.md:204

  • The documented sample count is stale: the showcase and the tested report contain findings across three tools (get_temp, echo, and add), not two. Update this line to match the actual CLI output.
error, 3 warnings across 2 tools. Re-run with --strict for details.` — and

Comment threadclients/cli/src/error-handler.ts
Comment threadtest-servers/configs/unportable-schemas-http.json
…y 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>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Review round 4 — all 6 addressed (2cb23a8b)

Two inline plus four suppressed, all valid. Mirroring here since the inline threads go outdated on push.

add was not actually runnable in the TUI

The one I'd have been most embarrassed to ship. I verified "every showcase tool is runnable" over the wire and against the web form, and never opened schemaToForm — which dispatches on type, so the $ref-only property fell through to a string field that add's numeric handler rejects. The README and the PR both made that claim, so it was false, not rough.

The override now keeps a local type alongside the remote reference, and restores the preset's required (which I had silently dropped on echo too). The remote-ref finding is unaffected — that rule reads $ref independently of type.

Two tests hold the claim up rather than my word: schemaToForm.test.ts asserts the property becomes a numeric field rather than the string fallback, and raw-tool-schemas.test.ts now calls every tool in the fixture with the arguments its advertised schema renders — including the properties the override added — since that is what a user clicking through the form sends. Writing the first also turned up that the TUI's numeric field kind is float, not number, so the assertion pins the real contract instead of an assumed one.

The new exit code was missing from codeForExit

Exactly the defect the round-3 rename was meant to prevent, one layer down: CliExitCodeError lets a caller omit the envelope, so an envelope-less throw reported the generic error. Added the case and covered it through the envelope-less construction that exposed it.

Suppressed comments — all four fixed

  • type-union fired on malformed arrays. Its message asserts the construct is legal JSON Schema, which is false for [] (matches nothing), [3], ["bananas"], or a duplicated member — and the anyOf it suggested for those would be invalid too. Now restricted to non-empty, unique arrays of recognized type names. Same principle the walk already applies to a node that is neither object nor boolean: malformed is the SDK parser's business, not this module's.
  • if/then/else were in the presence-only allowlist. They constrain only as a pair — if asserts nothing without a then/else, and either is ignored without an if — so {"if": {"const": 1}} accepts every value and evaded untyped-schema. The pairing is now checked explicitly. This is the same allowlist reasoning from round 1, applied to the one keyword group where presence alone is not enough.
  • Severity was carried by colour alone in the web sidebar: both branches rendered the same icon and the tooltip said "finding". Now a circle for an error and a triangle for a warning — a real shape difference — with the severity named in the tooltip as well as the accessible label. The TUI already did this with ! vs ?. Screenshot in the PR body re-captured.
  • Stale "across 2 tools" in the CLI README's hint sample: fixed.

npm run ci green.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

core/json/schemaLint.ts:412

  • A non-fragment $ref does not necessarily point outside the document. For example, when the root (or an embedded resource) has $id: "https://example.com/root", $ref: "https://example.com/root#/$defs/x" resolves to the already bundled resource and requires no remote fetch. This check flags that valid local reference and gives incorrect remediation. Track in-document $id resources and resolve the reference before classifying it as remote.
 const ref = node.$ref;
if (typeof ref === "string" && ref !== "" && !ref.startsWith("#")) {

clients/cli/src/cli.ts:1014

  • This validation runs after the servers/list and servers/show returns (lines 901–921) and the other short-circuit modes, so invocations such as --strict --method servers/list or --strict --list-stored-auth succeed while silently ignoring a flag documented as tools/list-only. Move the strict/method validation before every short-circuit return (and before stored-auth work), and cover one catalog or short-circuit invocation.
 if (options.strict && options.method !== "tools/list") {
throw new Error("--strict requires --method tools/list.");
}

Comment threadclients/web/src/components/groups/ToolListItem/ToolListItem.tsx Outdated
…ct checks
Copilot review round 5.
- The sidebar tooltip applied `hasError` to the TOTAL, so a tool with one
error and three warnings announced as "4 schema portability errors" —
a defect the previous round introduced while adding that wording, and
worse than the vague text it replaced. Both the tooltip and the
accessible label now carry the breakdown, formatted by a new shared
`summarizeToolFindings` so the fourth place to phrase a severity count
cannot drift from the other three.
- Suppress `remote-ref` when the document declares an `$id` anywhere.
`$id` establishes a base URI, so with a root `$id` of
`https://example.com/root` the ref `https://example.com/root#/$defs/x`
resolves to THIS document and needs no fetch — the finding and its
"inline the referenced schema" remediation would both be wrong.
Classifying correctly needs full RFC 3986 resolution against nested
embedded resources, which this module does not do, so it declines
rather than guessing. A missed finding is the acceptable direction.
- Run the `--strict` validations before every short-circuit return.
`servers/list`, `servers/show`, `--list-stored-auth` and
`--print-handoff` all return from `parseArgs` before any connect, so
the checks further down let `--strict --method servers/list` succeed
while silently ignoring a tools/list-only flag.
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>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Review round 5 — all 3 addressed (68c4abf3)

One inline plus two suppressed. Mirroring here since the inline thread goes outdated on push.

The tooltip miscounted mixed severities — and I introduced it last round

hasError was applied to the total, so a tool with one error and three warnings announced as "4 schema portability errors". That is worse than the vague "N findings" wording it replaced in round 4, because it is now confidently wrong instead of merely unspecific — and I added it while fixing a different accessibility problem on the same line.

Both the tooltip and the accessible label now carry the breakdown — Schema portability: 1 error, 3 warnings — with empty categories omitted. The formatting moved into a shared summarizeToolFindings in core/json/schemaLint.ts rather than staying inline: this is the fourth place that has had to phrase a severity count, and inlining it is exactly how the four drift apart. ToolListItem.test.tsx gained a mixed-severity case asserting the label is not "2 errors".

The icon still keys off hasError, which is correct — that is the highest severity and one glyph can only carry one. It was only the count that must not inherit it.

remote-ref fired on a local reference

A non-fragment $ref is not necessarily remote: $id establishes a base URI, so with a root $id of https://example.com/root, the ref https://example.com/root#/$defs/x resolves to this document and needs no fetch. The finding and its "inline the referenced schema" remediation would both have been wrong on a valid schema.

Classifying refs correctly there means full RFC 3986 base-URI resolution against possibly-nested embedded resources, which this module does not do. So it now declines to classify when any $id is declared anywhere in the document, rather than guessing — the same trade already made for malformed type arrays and for the removed root rule. A missed finding is the acceptable direction; a wrong one with wrong advice is not. $id is vanishingly rare in a tool schema, so this costs almost nothing, and the comment records what the refinement would be if it ever matters.

--strict was accepted-and-ignored on the short-circuit paths

servers/list, servers/show, --list-stored-auth and --print-handoff all return from parseArgs before any connect, so validations placed further down never ran for them: --strict --method servers/list succeeded while silently ignoring a flag documented as tools/list-only. Both --strict checks now run ahead of every short-circuit, covered by a table-driven case through the real runCli.

That is the same "accepted but inert" failure the --app-info pairing rejection exists to prevent — I fixed the instance in round 3 and did not check whether the placement of the check had the same hole.

npm run ci green.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 2 comments.

Suppressed comments (1)

clients/cli/README.md:173

  • This block is presented as the command's output, but formatSchemaLintReport emits one block for every finding before the summary. The documented fixture has three warning findings, so the summary can never immediately follow this error block as shown. Include the warning blocks or mark the middle as omitted so the example does not claim an output shape the CLI cannot produce.
Error: tool "get_temp"
Path: outputSchema.properties.data
Issue: Bare `true` used where a schema object is expected.
Suggestion: Declare what the value actually is — e.g. `{"type": "object", "additionalProperties": true}` for a free-form object. That narrows the schema deliberately; `true` accepts any JSON value at all.
1 error, 3 warnings across 3 tools.

Comment threadcore/json/schemaLint.ts Outdated
Comment threadclients/cli/src/handlers/schema-lint-report.ts Outdated
Copilot review round 6.
- Share one traversal between `walk` and `declaresAnyId`. The `$id` guard
added last round descended through EVERY object value, including
instance data — `examples: [{"$id": "x"}]`, a `default`, a `const` —
where `$id` is a payload key declaring no embedded resource. Reading
one there suppressed `remote-ref` for the whole document, turning last
round's false-positive fix into a blanket false negative. Both passes
now go through `forEachSubschema`, driven by the same keyword tables,
so they cannot drift about what counts as a schema position.
- Await the stderr writes. `writeSchemaLintReport` used a bare
`process.stderr.write`, but both CLI exit paths call `process.exit()`
as soon as the work returns, which discards anything still buffered on
a piped stderr — truncating or losing the multi-block `--strict`
report on exactly the redirected-output runs a CI caller uses. Added
`awaitableError` beside the existing `awaitableLog` and awaited it.
The test's fake defers its callback so a synchronous one cannot make
the assertion vacuous; two existing stderr stubs that never invoked
their callbacks were fixed in the same pass.
- Show the CLI README's sample report in full. It printed one error
block immediately followed by the summary, a shape the formatter
cannot produce — every finding gets a block. Captured from a real run.
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>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Review round 6 — all 3 addressed (488106f2)

Two inline plus one suppressed. Mirroring here since the inline threads go outdated on push.

The $id guard could be switched off by instance data

Third round running where the previous round's fix is the next round's defect — and this one inverted its own purpose. The $id scan I added in round 5 to prevent a false positive descended through every object value, including instance-valued keywords. examples: [{"$id": "payload-field"}] is data, not an embedded resource, but reading an $id there suppressed remote-ref for the whole document: a targeted false-positive fix became a blanket false negative.

Rather than copying the keyword tables into the pre-scan, I extracted the traversal both passes share — forEachSubschema, driven by the same SUBSCHEMA_MAP_KEYWORDS / SUBSCHEMA_ARRAY_KEYWORDS / SUBSCHEMA_KEYWORDS (tuple-items case included). walk recurses through it, declaresAnyId scans through it, so they cannot disagree about what a schema position is. Making the scan matchwalk by hand would have fixed today's bug and left the same shape in place for the next keyword added to one list and not the other.

The report could be truncated or lost

writeSchemaLintReport used a bare process.stderr.write. Both CLI exit paths call process.exit() as soon as the work returns, and on a pipe or a file — not a TTY — the write is asynchronous, so whatever is still buffered is discarded. Worst on the multi-block --strict report, and on precisely the redirected-output runs a CI caller uses.

The repo already had the answer and I walked past it: awaitableLog wraps stdout for exactly this reason. Added awaitableError beside it, made the function async, awaited it in emitResult before the throw. The test's fake returns false and defers its callback to a later tick — a fake that called back synchronously would have passed with or without the await. Writing it also turned up that the file's two existing stderr stubs never invoked their callbacks at all, so they would have hung the moment the function started awaiting.

The README sample showed an impossible shape

The block printed one error finding immediately followed by the summary, but the formatter emits a block for every finding — with three warnings in the fixture, that output cannot occur. Replaced with the complete real report, captured from a run rather than written by hand.

npm run ci green.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated 3 comments.

Comment threadcore/json/schemaLint.ts
Comment threadclients/cli/src/handlers/emit-result.ts
Comment threadclients/cli/README.md Outdated
… claim
Copilot review round 7.
- Add `contentSchema` to the traversal. It holds the schema the decoded
string content must satisfy, so a bare `true` or a remote `$ref` under
it is a real finding all three clients were walking past. Also admit
the three content keywords to `CONSTRAINING_KEYWORDS` on the same
documented grounds as `format` — annotation-only by vocabulary, but
plainly an attempt to constrain, so reporting such a schema as
accepting anything would be noise. The comment now names that
category rather than singling `format` out.
- Make `handleError` async and await the envelope write. Round 6 made
the `--strict` report await its write and stopped one line short: the
envelope emitted for the very same throw still raced `process.exit()`,
so the machine-readable line a CI caller parses could be lost on a
pipe. The launcher's comment claiming the envelope was "well inside
the pipe buffer" was wrong reasoning and is replaced — fitting in the
buffer says nothing about whether the write was performed. Its call
site now awaits; the bin's `.catch(handleError)` needed no change.
- The CLI README called its sample block the "complete output" when the
error envelope follows it on every non-zero exit. Now scoped to "the
complete report", with the envelope named and linked.
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>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Review round 7 — all 3 addressed (31bdd532)

Three inline comments, no suppressed ones. Mirroring here since the inline threads go outdated on push.

contentSchema was missing from the traversal

A standard 2019-09/2020-12 schema-valued keyword, absent from every table — so a bare true or a remote $ref under it was silently walked past by all three clients. Added, with a walk-position case.

I also admitted the three content keywords (contentMediaType, contentEncoding, contentSchema) to CONSTRAINING_KEYWORDS, which is the other half of getting this right. They are annotation-only in 2020-12's default vocabulary, so strictly a schema carrying one accepts any value — but that is the same call already made and documented for format, and calling such a schema "constrains nothing" would be noise. The comment now names that category instead of singling format out, so the next annotation-shaped keyword lands in an existing rationale rather than being decided ad hoc.

The envelope write raced process.exit()

This is the sharpest one: round 6 made the --strictreport await its write, and stopped one line short of the actual exit. The ErrorEnvelope emitted for the very same throw — the machine-readable line a CI caller parses — was still fire-and-forget. I fixed the symptom I had been shown and not the exit path it flows into.

handleError is now async and awaits before exiting. The launcher's call site awaits too, and I replaced the comment there that justified not awaiting: it argued the envelope is "a few hundred bytes, well inside the pipe buffer". That reasoning is wrong — fitting in the buffer says nothing about whether the write has been performed when the process goes away — and leaving it would have invited someone to drop the await again. The bin's .catch(handleError) needed no change; formatErrorOutput stays sync, so the in-process test runner is unaffected.

The new test's spy returns false and defers its callback, asserting the flush happened before process.exit. A synchronous spy passes with or without the await, which is exactly how this class stays hidden — and writing it surfaced that the two existing spies never invoked their callbacks at all.

"Complete output" wasn't

My wording from last round, added while fixing a different honesty problem in the same block. The error envelope follows the report on every non-zero exit, so the block was not the complete stderr. Now scoped to "the complete report", with the envelope named, linked, and its schema_unportable code shown.

npm run ci green.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 31 out of 31 changed files in this pull request and generated no new comments.

Suppressed comments (1)

core/json/schemaLint.ts:425

  • The claim that this replacement “narrows” the contract is false in non-monotonic positions. For example, {"not": true} currently rejects every value, but replacing the child with the suggested object schema makes the parent allow every non-object; an if: true replacement can similarly let values bypass then. Use parent-aware advice for not (where {} is an exact object-form replacement and is already exempted below), and describe the generic replacement as changing—not necessarily narrowing—the enclosing contract.
 'Declare what the value actually is — e.g. `{"type": "object", "additionalProperties": true}` for a free-form object. That narrows the schema deliberately; `true` accepts any JSON value at all.'

Copilot review round 8 (its one suppressed finding; no new inline comments).
The `boolean-schema` suggestion for a bare `true` said the replacement
"narrows the schema deliberately". That holds in an ordinary property
position and inverts under a negating parent: `{"not": true}` rejects
every value, so swapping in `{"type": "object", …}` leaves the parent
rejecting only objects and every non-object then passes — the enclosing
contract is widened. Under `if` it redirects which branch applies rather
than tightening anything.
- Parent-aware advice under `not`: suggest `{}`, which is the exact
object form of `true` and is already exempt from `untyped-schema` in
that position, with the message saying why a concrete type would be
wrong there.
- Everywhere else, and in the `untyped-schema` suggestion too, describe
the replacement as a deliberate CHANGE of contract rather than a
narrowing. A suggestion has no business claiming an effect it cannot
guarantee across the positions it can print from.
Tests pin the `not`-specific advice and assert no bare-`true` suggestion
in any of the three positions contains "narrow". The CLI README's sample
report is re-captured from a real run.
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>
@cliffhall

Copy link
Copy Markdown
MemberAuthor

Review round 8 — no new inline comments; the one suppressed finding is fixed (182c48f2)

Copilot generated no new comments this round. It did carry one suppressed finding, and it is correct and worth fixing rather than waving through.

"Narrows" was false in non-monotonic positions

The boolean-schema suggestion for a bare true claimed the replacement "narrows the schema deliberately". That holds in an ordinary property position and inverts under a negating parent: {"not": true} rejects every value, so swapping in {"type": "object", …} leaves the parent rejecting only objects and every non-object now passes. The enclosing contract is widened. Under if it neither narrows nor widens — it redirects which branch applies.

Two changes:

  • Parent-aware advice under not.{} and true are the same schema, so {} is always the exact rewrite; it is normally bad advice only because {} is the shape untyped-schema flags. Under not that exemption already exists (from round 5), so {} is both exact and safe to recommend there — and the message says why declaring a concrete type would be wrong in that position.
  • "Deliberate change of contract", not "narrows", everywhere else — in both the boolean-schema and untyped-schema suggestions. A suggestion has no business claiming an effect it cannot guarantee across positions.

Tests: a case pinning the not-specific advice, and one asserting that no suggestion for a bare true in any of the three positions contains the word "narrow". The CLI README's sample report was re-captured from a real run so the quoted text matches.


This is the eighth round, and the seven before it found a defect every time — five of them in the previous round's fix. Worth stating plainly what that pattern was, since it is the same one throughout: I kept validating a change against the problem it was asked to solve, not against what the change itself then asserted. The $id guard checked that it stopped a false positive but not that data could trigger it; the awaited report write checked the report but not the envelope on the same throw; this suggestion checked that it was honest about not being an equivalent rewrite, but not that "narrows" was true in every position it can print from.

npm run ci green.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 31 out of 31 changed files in this pull request and generated no new comments.

@cliffhall
cliffhall merged commit 68a9d0b into v2/mainAug 25, 2026
1 check passed
@cliffhall
cliffhall deleted the v2/feat/1005-schema-lint branch August 25, 2026 13:17
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Strict JSON Schema validation with actionable error messages in CLI mode

2 participants

@cliffhall