inspector/clients/cli/__tests__/error-handler.test.ts
cliffhall 19a6df7ad3 SDK v2 migration (behavior-neutral, versionNegotiation: 'legacy') (#1624)
Migrate all three clients + core + test-servers from
@modelcontextprotocol/sdk@1.29 to SDK v2 (2.0.0-beta.4), keeping behavior
identical against legacy (2025-11-25) servers. Foundation for the SDK V2 +
New Spec stack.

Packages
- Split @modelcontextprotocol/sdk -> @modelcontextprotocol/client (Client,
  transports, OAuth, error taxonomy, protocol types) + core (Zod schemas);
  stdio -> client/stdio, Ajv -> client/validators/ajv. Test-servers ->
  @modelcontextprotocol/server (+ server-legacy/sse). Deterministic import
  rewrite (schemas -> core, everything else -> client); the codemod misroutes
  client code to /server so it was not used for path routing.
- new Client(..., { versionNegotiation: { mode: 'legacy' } }) — nothing
  2026-era goes on the wire.

Runtime (core/mcp/inspectorClient.ts)
- Error taxonomy: McpError -> ProtocolError, ErrorCode -> ProtocolErrorCode;
  RequestTimeout -> SdkError/SdkErrorCode.
- Handlers take method strings; task methods (tasks/*, notifications/tasks/*)
  use the 3-arg custom form (excluded from v2's spec-method set).
- Legacy tasks kept working (per project requirement): requestor tasks re-
  implemented over raw client.request + explicit deprecated schemas;
  experimental.tasks.callToolStream replaced by a local pollTaskToolCall
  async generator (with _progressHandlers revival so task-execution progress
  still reaches the caller). Receiver (server-initiated) tasks: the v2 Client
  validates a sampling/elicit handler result and rejects the legacy { task }
  response with -32602 — installReceiverTaskResponseBypass swaps the wrapped
  entry in the private _requestHandlers map for the task-augmented branch so
  { task } reaches the wire (fixes server-initiated sampling-via-task).
- Pagination: list verbs use raw client.request({method}, List*ResultSchema)
  for single-page behavior (v2's client.listX() auto-aggregate all pages).

Web / auth / apps
- Rename local JsonSchemaType -> InspectorFormSchema + add toFormSchema()
  structural narrowing at the SDK-schema -> form boundary.
- ext-apps still peers on SDK v1: one compat boundary with documented casts
  (remove when ext-apps#702 lands a v2 peer).
- OAuth provider saveClientInformation reconciled with v2's
  OAuthClientInformationContext; InvalidClientMetadataError re-homed as a
  local class (server-legacy/auth is not browser-safe).
- vitest.shared.mts dedupes @modelcontextprotocol/client+core to one copy so
  vi.mock intercepts the SUT's import.

Accepted v2 behavior changes (per #1624 / spec §9.2): unknown-tool call
rejects with -32602; cancellation is stream-abort (no guaranteed
notifications/cancelled frame); output-schema strictness restored via the
Inspector's own Ajv check on the default path.

npm run ci green: validate (web 3087 / cli 212 / tui 274 / launcher 5),
coverage (>=90 per-file, all clients), smoke (launcher/cli/web), storybook
(410).

Closes #1624

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:44:24 -04:00

235 lines
8.6 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from "vitest";
import {
CliExitCodeError,
EXIT_CODES,
classifyError,
formatErrorOutput,
handleError,
} from "../src/error-handler.js";
/**
* `handleError` is the binary's last-resort error sink (wired up in
* `src/index.ts`). It is exercised in-process here — rather than only through
* the spawned binary — so its source is measured under the CLI coverage gate
* (#1484). `process.exit` is stubbed so asserting on it doesn't tear down the
* test worker.
*/
describe("handleError", () => {
afterEach(() => {
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);
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation((() => undefined) as never);
handleError(new Error("boom"));
expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.USAGE);
const written = writeSpy.mock.calls[0]![0] as string;
const parsed = JSON.parse(written) as {
error: { code: string; message: string };
};
expect(parsed.error.code).toBe("error");
expect(parsed.error.message).toBe("boom");
});
it("uses a CliExitCodeError's exitCode and envelope code", () => {
vi.spyOn(process.stderr, "write").mockImplementation((() => true) as never);
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation((() => undefined) as never);
handleError(
new CliExitCodeError(EXIT_CODES.NO_APP, "no app", { code: "no_app" }),
);
expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.NO_APP);
});
});
describe("classifyError", () => {
it("classifies a 401 status as AUTH_REQUIRED", () => {
const err = Object.assign(new Error("Unauthorized"), { status: 401 });
const { exitCode, envelope } = classifyError(err, {
url: "https://x.example/mcp",
});
expect(exitCode).toBe(EXIT_CODES.AUTH_REQUIRED);
expect(envelope.code).toBe("auth_required");
expect(envelope.status).toBe(401);
expect(envelope.url).toBe("https://x.example/mcp");
});
it("classifies a WWW-Authenticate message as AUTH_REQUIRED without a status", () => {
const { exitCode } = classifyError(
new Error("Dynamic client registration failed: WWW-Authenticate Bearer"),
);
expect(exitCode).toBe(EXIT_CODES.AUTH_REQUIRED);
});
it("classifies ENOTFOUND / fetch failed as UNREACHABLE", () => {
const err = new Error("fetch failed");
(err as { cause?: unknown }).cause = new Error(
"getaddrinfo ENOTFOUND no.such.host",
);
const { exitCode, envelope } = classifyError(err);
expect(exitCode).toBe(EXIT_CODES.UNREACHABLE);
expect(envelope.cause).toContain("ENOTFOUND");
});
it("classifies a connection-refused cause as UNREACHABLE", () => {
const err = new Error("connect ECONNREFUSED 127.0.0.1:1");
expect(classifyError(err).exitCode).toBe(EXIT_CODES.UNREACHABLE);
});
it("falls back to USAGE for an unrecognized Error", () => {
expect(classifyError(new Error("something else")).exitCode).toBe(
EXIT_CODES.USAGE,
);
});
it("handles a string error", () => {
const { exitCode, envelope } = classifyError("plain failure");
expect(exitCode).toBe(EXIT_CODES.USAGE);
expect(envelope.message).toBe("plain failure");
});
it("handles a non-Error, non-string value", () => {
const { envelope } = classifyError({ unexpected: true });
expect(envelope.message).toBe("Unknown error");
});
it("reads an HTTP status from a nested response object", () => {
const err = { response: { status: 401 } };
const { exitCode, envelope } = classifyError(err);
expect(exitCode).toBe(EXIT_CODES.AUTH_REQUIRED);
expect(envelope.status).toBe(401);
});
it("preserves a CliExitCodeError's explicit envelope code over the default", () => {
const { envelope } = classifyError(
new CliExitCodeError(EXIT_CODES.TOOL_ERROR, "x", {
code: "tool_not_found",
}),
);
expect(envelope.code).toBe("tool_not_found");
});
it("carries a CliExitCodeError's explicit status and url onto the envelope", () => {
const { envelope } = classifyError(
new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "denied", {
status: 401,
url: "https://api.example/mcp",
}),
);
expect(envelope.status).toBe(401);
expect(envelope.url).toBe("https://api.example/mcp");
});
it("falls back to the context url for a CliExitCodeError without its own url", () => {
const { envelope } = classifyError(
new CliExitCodeError(EXIT_CODES.UNREACHABLE, "down"),
{ url: "https://ctx.example/mcp" },
);
expect(envelope.url).toBe("https://ctx.example/mcp");
});
it("derives a default envelope code for a bare CliExitCodeError", () => {
expect(
classifyError(new CliExitCodeError(EXIT_CODES.UNREACHABLE, "x")).envelope
.code,
).toBe("unreachable");
expect(
classifyError(new CliExitCodeError(EXIT_CODES.NO_APP, "x")).envelope.code,
).toBe("no_app");
expect(
classifyError(new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "x"))
.envelope.code,
).toBe("auth_required");
expect(
classifyError(new CliExitCodeError(EXIT_CODES.TOOL_ERROR, "x")).envelope
.code,
).toBe("tool_error");
expect(
classifyError(new CliExitCodeError(EXIT_CODES.USAGE, "x")).envelope.code,
).toBe("error");
});
it("terminates on a self-referential cause chain without recursing infinitely", () => {
// A cyclic `error.cause` must not hang the classifier; the depth cap bounds
// the walk and returns a finite string.
const err = new Error("looping");
(err as { cause?: unknown }).cause = err;
const { envelope } = classifyError(err);
expect(envelope.message).toBe("looping");
// Bounded by the depth cap: a finite string, not an infinite loop.
expect(typeof envelope.cause).toBe("string");
expect(envelope.cause).toContain("looping");
});
it("captures a non-Error cause as a string", () => {
const err = new Error("outer");
(err as { cause?: unknown }).cause = { reason: "blocked" };
const { envelope } = classifyError(err);
expect(envelope.cause).toBe("[object Object]");
});
it("flattens a nested Error cause chain into one string", () => {
const inner = new Error("getaddrinfo ENOTFOUND host");
const middle = Object.assign(new Error("connect failed"), { cause: inner });
const outer = Object.assign(new Error("fetch failed"), { cause: middle });
const { envelope } = classifyError(outer);
expect(envelope.cause).toBe("connect failed: getaddrinfo ENOTFOUND host");
});
it("reads a numeric SDK-style .code as an HTTP status", () => {
const err = Object.assign(new Error("forbidden"), { code: 403 });
const { exitCode, envelope } = classifyError(err);
expect(exitCode).toBe(EXIT_CODES.AUTH_REQUIRED);
expect(envelope.status).toBe(403);
});
it("ignores a JSON-RPC ProtocolError numeric .code (outside the HTTP range) as a status", () => {
// MCP SDK's ProtocolError carries a numeric `.code` that is a JSON-RPC error
// code (e.g. -32601 MethodNotFound), NOT an HTTP status. It must not leak
// into `status`, nor get misclassified as AUTH_REQUIRED.
const err = Object.assign(new Error("Method not found"), { code: -32601 });
const { exitCode, envelope } = classifyError(err);
expect(exitCode).toBe(EXIT_CODES.USAGE);
expect(envelope.status).toBeUndefined();
});
it("ignores a non-numeric node error code when reading the status", () => {
const err = Object.assign(new Error("getaddrinfo ENOTFOUND host"), {
code: "ENOTFOUND",
});
const { exitCode, envelope } = classifyError(err);
// The message matches the UNREACHABLE pattern, and the string node code
// must NOT be treated as an HTTP status.
expect(exitCode).toBe(EXIT_CODES.UNREACHABLE);
expect(envelope.status).toBeUndefined();
});
});
describe("formatErrorOutput", () => {
it("emits one JSON line on stderr ending in a newline", () => {
const { stderr, exitCode } = formatErrorOutput(new Error("nope"));
expect(stderr.endsWith("\n")).toBe(true);
expect(stderr.split("\n").length).toBe(2);
const parsed = JSON.parse(stderr) as { error: { message: string } };
expect(parsed.error.message).toBe("nope");
expect(exitCode).toBe(EXIT_CODES.USAGE);
});
it("threads the context url into the envelope", () => {
const { stderr } = formatErrorOutput(new Error("nope"), {
url: "https://ctx.example/mcp",
});
const parsed = JSON.parse(stderr) as { error: { url?: string } };
expect(parsed.error.url).toBe("https://ctx.example/mcp");
});
});