connectionTimeout defaulted to 0, which InspectorClient.connect() read as "no bound at all" — so out of the box nothing the Inspector owns bounded a connect attempt, and the only thing ending a hung handshake was the SDK's 60 s per-request timeout on initialize, reporting "Request timed out". - Add DEFAULT_CONNECTION_TIMEOUT_MS (30000) to core/mcp/types.ts. An absent value resolves to it on the client, on disk read, in the web form draft, and in the CLI's header-only settings shell. An explicit 0 keeps its documented meaning (no timeout; --connect-timeout 0) and is now persisted so the opt-out round-trips; the default is the omit-sentinel, as for taskTtl. - Key the post-timeout transport teardown to the timer actually firing, not to connectionTimeout > 0 — otherwise a non-zero default would tear down every failed connect, including a recoverable 401 whose transport the auth recovery holds open. - The timeout message names the bound and where to raise it: "Connection timed out after N ms. To accommodate a slower server, you may increase the timeout value in Server Settings." - The CLI's UNREACHABLE classifier now matches that message. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4h8Xa6Ed9zwtv5ojX35MT Signed-off-by: cliffhall <cliff@futurescale.com>
261 lines
9 KiB
TypeScript
261 lines
9 KiB
TypeScript
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:
|
|
*
|
|
* - 0: success
|
|
* - 1: usage / unexpected error (the catch-all; same as before this map)
|
|
* - 2: `--app-info` probe found no MCP App on the tool
|
|
* - 3: server requires authentication (401 / WWW-Authenticate / OAuth)
|
|
* - 4: server unreachable (DNS, connect refused, timeout, fetch failure)
|
|
* - 5: tool error (`tools/call` returned `isError:true`, or tool not found)
|
|
* - 6: `--strict` found an error-severity tool-schema portability finding
|
|
*
|
|
* Note 6 is `SCHEMA_UNPORTABLE`, not "invalid": the whole premise of the lint
|
|
* is that these schemas ARE valid JSON Schema and are merely refused by some
|
|
* clients. Calling the outcome "invalid" would misreport it to the automated
|
|
* callers this map exists for.
|
|
*/
|
|
export const EXIT_CODES = {
|
|
OK: 0,
|
|
USAGE: 1,
|
|
NO_APP: 2,
|
|
AUTH_REQUIRED: 3,
|
|
UNREACHABLE: 4,
|
|
TOOL_ERROR: 5,
|
|
SCHEMA_UNPORTABLE: 6,
|
|
/**
|
|
* `--verify` found a SEP-2640 violation: a conformance error, a digest or
|
|
* size mismatch, or a manifest file that could not be read (#2248).
|
|
*
|
|
* Its own code rather than reusing `SCHEMA_UNPORTABLE`, for the reason that
|
|
* one exists at all: a CI job that fails on an unportable tool schema and a
|
|
* CI job that fails on a tampered skill digest are different jobs, and
|
|
* collapsing them would make `if [ $? -eq 6 ]` ambiguous.
|
|
*/
|
|
SKILL_NONCONFORMANT: 7,
|
|
/**
|
|
* `--verify` could not check the whole catalog: the read bounds stopped the
|
|
* walk before it finished (#2248).
|
|
*
|
|
* Distinct from `SKILL_NONCONFORMANT` because the server has broken no
|
|
* **MUST** — SEP-2640 states the 512-entry and 16 MiB limits as SHOULD NOT,
|
|
* with hosts free to support more — so exiting 7 would call a conforming
|
|
* server nonconformant. It is still non-zero, because reporting success for
|
|
* a manifest whose unread entries were never fetched is a false pass. A CI
|
|
* job that wants to tolerate oversized catalogs can allow 8 and still fail
|
|
* on 7.
|
|
*/
|
|
SKILL_INCOMPLETE: 8,
|
|
} as const;
|
|
|
|
/** Machine-readable error envelope written as one JSON line on stderr. */
|
|
export interface ErrorEnvelope {
|
|
/** Stable identifier for the failure class (e.g. "auth_required"). */
|
|
code: string;
|
|
/** Human-readable message. */
|
|
message: string;
|
|
/** The underlying error's `cause` (e.g. undici's ENOTFOUND), if any. */
|
|
cause?: string;
|
|
/** HTTP status when the failure was an HTTP-level response. */
|
|
status?: number;
|
|
/** The server URL the failure was against, when known. */
|
|
url?: string;
|
|
}
|
|
|
|
/**
|
|
* Thrown by the CLI to request a specific non-zero exit code without routing
|
|
* through the generic error path. {@link formatErrorOutput} reads `exitCode`
|
|
* and `envelope`; the in-process test runner does the same so tests observe
|
|
* the real code and stderr.
|
|
*/
|
|
export class CliExitCodeError extends Error {
|
|
constructor(
|
|
public readonly exitCode: number,
|
|
message: string,
|
|
public readonly envelope?: Partial<ErrorEnvelope>,
|
|
) {
|
|
super(message);
|
|
this.name = "CliExitCodeError";
|
|
}
|
|
}
|
|
|
|
/** Depth cap for {@link causeOf} — bounds a pathological/self-referential
|
|
* `error.cause` chain so a cycle can never recurse infinitely. */
|
|
const MAX_CAUSE_DEPTH = 16;
|
|
|
|
/** Read an `Error.cause` chain into a single readable string, if present. */
|
|
function causeOf(error: unknown, depth = 0): string | undefined {
|
|
if (depth >= MAX_CAUSE_DEPTH) return undefined;
|
|
if (!(error instanceof Error)) return undefined;
|
|
const cause = (error as { cause?: unknown }).cause;
|
|
if (cause === undefined || cause === null) return undefined;
|
|
if (cause instanceof Error) {
|
|
const nested = causeOf(cause, depth + 1);
|
|
return nested ? `${cause.message}: ${nested}` : cause.message;
|
|
}
|
|
return String(cause);
|
|
}
|
|
|
|
/** Best-effort HTTP status from common error shapes (undici, MCP SDK, fetch). */
|
|
function statusOf(error: unknown): number | undefined {
|
|
if (error == null || typeof error !== "object") return undefined;
|
|
const e = error as {
|
|
status?: unknown;
|
|
code?: unknown;
|
|
response?: { status?: unknown };
|
|
};
|
|
if (typeof e.status === "number") return e.status;
|
|
if (typeof e.response?.status === "number") return e.response.status;
|
|
// SDK SseError / StreamableHTTPError expose `.code` as the HTTP status. Guard
|
|
// to the HTTP range (100-599) so a JSON-RPC ProtocolError code (e.g. -32601
|
|
// MethodNotFound) is not mistaken for an HTTP status and leaked into the
|
|
// envelope or misclassified as AUTH_REQUIRED. String node codes like
|
|
// "ENOTFOUND" are already excluded by the numeric check.
|
|
if (typeof e.code === "number" && e.code >= 100 && e.code <= 599) {
|
|
return e.code;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const UNREACHABLE_PATTERN =
|
|
/ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN|ETIMEDOUT|fetch failed|getaddrinfo|connect(?:ion)? timed out|aborted/i;
|
|
|
|
/**
|
|
* Classify an arbitrary error into an exit code and envelope. Used both by the
|
|
* binary's {@link handleError} and by callers that want to throw a
|
|
* {@link CliExitCodeError} with the right code up front.
|
|
*/
|
|
export function classifyError(
|
|
error: unknown,
|
|
context?: { url?: string },
|
|
): { exitCode: number; envelope: ErrorEnvelope } {
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: typeof error === "string"
|
|
? error
|
|
: "Unknown error";
|
|
const cause = causeOf(error);
|
|
const status = statusOf(error);
|
|
const url = context?.url;
|
|
|
|
// A pre-classified error carries its own exit code; fill in any envelope
|
|
// fields the thrower didn't supply.
|
|
if (error instanceof CliExitCodeError) {
|
|
return {
|
|
exitCode: error.exitCode,
|
|
envelope: {
|
|
code: error.envelope?.code ?? codeForExit(error.exitCode),
|
|
message,
|
|
...(cause !== undefined && { cause }),
|
|
...(error.envelope?.status !== undefined && {
|
|
status: error.envelope.status,
|
|
}),
|
|
...((error.envelope?.url ?? url) !== undefined && {
|
|
url: error.envelope?.url ?? url,
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
// 401 / OAuth-required → AUTH_REQUIRED so the caller can kick the auth flow.
|
|
if (
|
|
status === 401 ||
|
|
status === 403 ||
|
|
/WWW-Authenticate|Unauthorized|invalid_token|OAuth/i.test(
|
|
message + " " + (cause ?? ""),
|
|
)
|
|
) {
|
|
return {
|
|
exitCode: EXIT_CODES.AUTH_REQUIRED,
|
|
envelope: {
|
|
code: "auth_required",
|
|
message,
|
|
...(cause !== undefined && { cause }),
|
|
...(status !== undefined && { status }),
|
|
...(url !== undefined && { url }),
|
|
},
|
|
};
|
|
}
|
|
|
|
// Network-layer failure → UNREACHABLE so the caller can retry via a proxy.
|
|
if (UNREACHABLE_PATTERN.test(message + " " + (cause ?? ""))) {
|
|
return {
|
|
exitCode: EXIT_CODES.UNREACHABLE,
|
|
envelope: {
|
|
code: "unreachable",
|
|
message,
|
|
...(cause !== undefined && { cause }),
|
|
...(status !== undefined && { status }),
|
|
...(url !== undefined && { url }),
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
exitCode: EXIT_CODES.USAGE,
|
|
envelope: {
|
|
code: "error",
|
|
message,
|
|
...(cause !== undefined && { cause }),
|
|
...(status !== undefined && { status }),
|
|
...(url !== undefined && { url }),
|
|
},
|
|
};
|
|
}
|
|
|
|
function codeForExit(exitCode: number): string {
|
|
switch (exitCode) {
|
|
case EXIT_CODES.NO_APP:
|
|
return "no_app";
|
|
case EXIT_CODES.AUTH_REQUIRED:
|
|
return "auth_required";
|
|
case EXIT_CODES.UNREACHABLE:
|
|
return "unreachable";
|
|
case EXIT_CODES.TOOL_ERROR:
|
|
return "tool_error";
|
|
case EXIT_CODES.SCHEMA_UNPORTABLE:
|
|
return "schema_unportable";
|
|
default:
|
|
return "error";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Single source of truth for mapping a thrown error to the CLI's exit code and
|
|
* stderr text. The binary entry ({@link handleError}) and the in-process test
|
|
* runner both call this, so tests observe exactly what the binary would emit.
|
|
*
|
|
* stderr is one JSON line `{"error":{...}}` so a caller can `2>&1 | tail -1 |
|
|
* jq .error`; the message is inside the envelope so nothing is lost vs. the
|
|
* previous bare-message behaviour.
|
|
*/
|
|
export function formatErrorOutput(
|
|
error: unknown,
|
|
context?: { url?: string },
|
|
): { exitCode: number; stderr: string } {
|
|
const { exitCode, envelope } = classifyError(error, context);
|
|
return {
|
|
exitCode,
|
|
stderr: JSON.stringify({ error: envelope }) + "\n",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
await awaitableError(stderr);
|
|
process.exit(exitCode);
|
|
}
|