fix: walk contentSchema, await the error envelope, correct the README 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>
This commit is contained in:
cliffhall 2026-08-25 03:26:39 -04:00
commit 31bdd532a6
3 changed files with 97 additions and 20 deletions

View file

@ -164,8 +164,10 @@ on **stderr**, and exits `6` if any is error-severity:
mcp-inspector --cli <server> --method tools/list --strict
```
Complete output against the `unportable-schemas-http.json` showcase — one
block per finding, then the summary:
The complete **report** against the `unportable-schemas-http.json` showcase —
one block per finding, then the summary. On a non-zero exit the shared error
handler adds one more stderr line after this, the
[`ErrorEnvelope`](#exit-codes--error-envelopes) (`{"error":{"code":"schema_unportable",…}}`):
```text
Error: tool "get_temp"

View file

@ -19,15 +19,19 @@ describe("handleError", () => {
vi.restoreAllMocks();
});
it("emits a JSON error envelope on stderr and exits with the classified code", () => {
const writeSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation((() => true) as never);
it("emits a JSON error envelope on stderr and exits with the classified code", async () => {
const writeSpy = vi.spyOn(process.stderr, "write").mockImplementation(((
_chunk: unknown,
cb?: () => void,
) => {
cb?.();
return true;
}) as never);
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation((() => undefined) as never);
handleError(new Error("boom"));
await handleError(new Error("boom"));
expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.USAGE);
const written = writeSpy.mock.calls[0]![0] as string;
@ -38,18 +42,49 @@ describe("handleError", () => {
expect(parsed.error.message).toBe("boom");
});
it("uses a CliExitCodeError's exitCode and envelope code", () => {
vi.spyOn(process.stderr, "write").mockImplementation((() => true) as never);
it("uses a CliExitCodeError's exitCode and envelope code", async () => {
vi.spyOn(process.stderr, "write").mockImplementation(((
_chunk: unknown,
cb?: () => void,
) => {
cb?.();
return true;
}) as never);
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation((() => undefined) as never);
handleError(
await handleError(
new CliExitCodeError(EXIT_CODES.NO_APP, "no app", { code: "no_app" }),
);
expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.NO_APP);
});
it("exits only after stderr has taken the envelope", async () => {
// The whole point of the await: a pipe defers the write, and
// `process.exit()` discards anything still queued. A spy that called back
// synchronously would pass with or without it, so this one defers.
let flushed = false;
vi.spyOn(process.stderr, "write").mockImplementation(((
_chunk: unknown,
cb?: () => void,
) => {
setTimeout(() => {
flushed = true;
cb?.();
}, 0);
return false;
}) as never);
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation((() => undefined) as never);
await handleError(new Error("boom"));
expect(flushed).toBe(true);
expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.USAGE);
});
});
describe("classifyError", () => {

View file

@ -1,3 +1,5 @@
import { awaitableError } from "./utils/awaitable-log.js";
/**
* Exit-code map. Non-zero codes let an automated caller (CI, an agent) branch
* on the failure class without regex-scraping stderr:
@ -218,8 +220,19 @@ export function formatErrorOutput(
};
}
export function handleError(error: unknown): never {
/**
* The binary's last-resort error sink: write the envelope, then exit.
*
* **Async, and the await is load-bearing.** On a pipe or a file — as opposed
* to a TTY — `process.stderr.write` is asynchronous, and `process.exit()`
* discards whatever is still queued. Size is not the safeguard it looks like:
* the envelope being small enough for the pipe buffer says nothing about
* whether the write has been *performed* by the time the process goes away.
* So this settles on the write callback first, exactly as the `--strict`
* schema report does.
*/
export async function handleError(error: unknown): Promise<never> {
const { exitCode, stderr } = formatErrorOutput(error);
process.stderr.write(stderr);
await awaitableError(stderr);
process.exit(exitCode);
}