diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts index 7fe7fb6..a9116de 100644 --- a/src/commands/helpers.ts +++ b/src/commands/helpers.ts @@ -63,6 +63,33 @@ export function c(code: string, text: string): string { return useColor() ? `${code}${text}${ANSI.reset}` : text; } +/** + * Suggests a one-line remediation for a raw error message, so scan/test + * failures tell the user how to fix the problem, not just state it. + * + * Note: connection failures during MCP session startup already get a much + * richer diagnosis (diagnosis/likely causes/next steps) from + * `formatConnectionFailureDiagnosis` via `RunArtifact.fatalError` — this + * covers the lighter-weight cases outside that path (thrown exceptions, + * config parsing errors) where no such diagnosis exists. + */ +export function suggestFix(message: string): string | undefined { + const lower = message.toLowerCase(); + if (lower.includes("timed out") || lower.includes("timeout")) { + return "Try increasing timeout with --timeout 30000"; + } + if (lower.includes("econnrefused") || lower.includes("connection refused")) { + return "Check that the server is running. Try: npx -y "; + } + if (lower.includes("enoent") || lower.includes("spawn")) { + return "Check that the command is installed and on PATH. Try: npx -y "; + } + if (lower.includes("unexpected token") || lower.includes("invalid config") || lower.includes("is not valid json")) { + return "Check target JSON format. Run: cat "; + } + return undefined; +} + export function colorStatus(status: string): string { switch (status) { case "pass": diff --git a/src/commands/scan.ts b/src/commands/scan.ts index 49a0467..d45208a 100644 --- a/src/commands/scan.ts +++ b/src/commands/scan.ts @@ -11,7 +11,8 @@ import type { RunArtifact } from "../types.js"; import { TOOL_VERSION } from "../version.js"; import { maybePrintCloudCta } from "../commercial.js"; import { renderActionReceipt } from "../action-receipt.js"; -import { ANSI, LOGO, c, isQuiet, setupCiHint, useColor } from "./helpers.js"; +import { ANSI, LOGO, c, isQuiet, setupCiHint, suggestFix, useColor } from "./helpers.js"; +import { firstNextStep } from "../utils/failure-diagnosis.js"; import { maybeConvertPassingCheckToCi, type SetupCiConversionFlags } from "./setup-ci-conversion.js"; // ── Scan implementation ───────────────────────────────────────────────────── @@ -141,6 +142,10 @@ async function runScan( if (artifact.fatalError) { process.stdout.write(` ${c(ANSI.red, "→")} ${artifact.fatalError.split("\n")[0]}\n`); + const fixHint = firstNextStep(artifact.fatalError) ?? suggestFix(artifact.fatalError); + if (fixHint) { + process.stdout.write(` ${c(ANSI.dim, `→ ${fixHint}`)}\n`); + } } else if (artifact.gate === "fail" && diagnostics.length > 0) { process.stdout.write(` ${c(ANSI.dim, "→")} ${diagnostics[0]}\n`); } @@ -173,6 +178,11 @@ async function runScan( process.stdout.write(`\r ${c(ANSI.red, "✗")} ${c(ANSI.bold, t.config.targetId)}\n`); process.stdout.write(` ${c(ANSI.red, friendlyMsg)}\n`); + const fixHint = suggestFix(msg); + if (fixHint) { + process.stdout.write(` ${c(ANSI.dim, `→ ${fixHint}`)}\n`); + } + // Docker-specific hint const serverCmd = t.config.adapter === "local-process" ? (t.config as { command: string }).command : ""; if (serverCmd === "docker" || serverCmd.startsWith("docker ")) { diff --git a/src/commands/test.ts b/src/commands/test.ts index 872eac2..30384eb 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -16,7 +16,8 @@ import { renderActionReceipt } from "../action-receipt.js"; import { extractObservatoryFindings } from "../findings.js"; import { runEnforce } from "./enforce.js"; import { renderNextActions } from "../reporters/terminal.js"; -import { ANSI, c, resolveTarget, targetFromCommand, writeOutput } from "./helpers.js"; +import { ANSI, c, resolveTarget, suggestFix, targetFromCommand, writeOutput } from "./helpers.js"; +import { firstNextStep } from "../utils/failure-diagnosis.js"; import { maybeConvertPassingCheckToCi, type SetupCiConversionFlags } from "./setup-ci-conversion.js"; export interface TestCommandFlags extends SetupCiConversionFlags { @@ -170,6 +171,14 @@ export function registerTestCommands(program: Command): void { process.stdout.write(`\r ${gateIcon} ${c(ANSI.bold, target.targetId)}${" ".repeat(Math.max(1, 40 - target.targetId.length))}`); process.stdout.write(`${c(ANSI.dim, `${toolCount} tools, ${promptCount} prompts, ${resourceCount} resources`)}\n`); + if (artifact.fatalError) { + process.stdout.write(` ${c(ANSI.red, "→")} ${artifact.fatalError.split("\n")[0]}\n`); + const fixHint = firstNextStep(artifact.fatalError) ?? suggestFix(artifact.fatalError); + if (fixHint) { + process.stdout.write(` ${c(ANSI.dim, `→ ${fixHint}`)}\n`); + } + } + for (const check of artifact.checks) { if (check.status === "fail" || check.status === "partial") { process.stdout.write(` ${c(ANSI.dim, "→")} ${check.id}: ${check.message}\n`); diff --git a/src/utils/failure-diagnosis.ts b/src/utils/failure-diagnosis.ts index 3d5a2ca..ac58361 100644 --- a/src/utils/failure-diagnosis.ts +++ b/src/utils/failure-diagnosis.ts @@ -134,3 +134,17 @@ export function formatConnectionFailureDiagnosis( return lines.join("\n"); } + +/** + * Pulls the first "Next steps:" bullet out of a formatted diagnosis (as + * produced by formatConnectionFailureDiagnosis / stored in + * RunArtifact.fatalError), for callers that only have room for a single + * remediation line rather than the full diagnosis block. + */ +export function firstNextStep(formattedDiagnosis: string): string | undefined { + const lines = formattedDiagnosis.split("\n"); + const idx = lines.findIndex((line) => line === "Next steps:"); + if (idx === -1) return undefined; + const step = lines[idx + 1]; + return step?.startsWith("-") ? step.slice(1).trim() : undefined; +} diff --git a/tests/commands-helpers.test.ts b/tests/commands-helpers.test.ts index a448896..ce98eaf 100644 --- a/tests/commands-helpers.test.ts +++ b/tests/commands-helpers.test.ts @@ -14,6 +14,7 @@ import { setNoColor, setQuiet, setupCiHint, + suggestFix, targetFromCommand, useColor, writeOutput, @@ -91,7 +92,39 @@ describe("command helper formatting", () => { setQuiet(false); expect(isQuiet()).toBe(false); }); +}); + +describe("suggestFix", () => { + it("suggests raising the timeout for timeout errors", () => { + expect(suggestFix("Server timed out after 15000ms")).toBe( + "Try increasing timeout with --timeout 30000", + ); + }); + + it("suggests checking the server is running for connection-refused errors", () => { + expect(suggestFix("connect ECONNREFUSED 127.0.0.1:3000")).toBe( + "Check that the server is running. Try: npx -y ", + ); + }); + + it("suggests checking PATH for ENOENT / spawn errors", () => { + expect(suggestFix("spawn some-cli ENOENT")).toBe( + "Check that the command is installed and on PATH. Try: npx -y ", + ); + }); + + it("suggests checking JSON format for config parsing errors", () => { + expect(suggestFix("Unexpected token } in JSON at position 42")).toBe( + "Check target JSON format. Run: cat ", + ); + }); + + it("returns undefined for messages that don't match a known pattern", () => { + expect(suggestFix("something completely unrelated happened")).toBeUndefined(); + }); +}); +describe("command helper formatting continued", () => { it("colors known statuses and leaves unknown statuses unchanged", () => { process.env["NO_COLOR"] = "1"; expect(colorStatus("pass")).toBe("pass"); diff --git a/tests/failure-diagnosis.test.ts b/tests/failure-diagnosis.test.ts new file mode 100644 index 0000000..2098f94 --- /dev/null +++ b/tests/failure-diagnosis.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { firstNextStep, formatConnectionFailureDiagnosis } from "../src/utils/failure-diagnosis.js"; +import type { TargetConfig } from "../src/types.js"; + +const target: TargetConfig = { + targetId: "test", + adapter: "local-process" as const, + command: "test-cmd", + args: [], +}; + +describe("firstNextStep", () => { + it("extracts the first next-step bullet from a formatted diagnosis", () => { + const diagnosis = formatConnectionFailureDiagnosis(target, "MCP error -32000: Connection closed", []); + expect(firstNextStep(diagnosis)).toBe( + "Run the command manually and look for usage output, auth prompts, or crash text.", + ); + }); + + it("returns undefined for text with no Next steps section", () => { + expect(firstNextStep("just a plain error message")).toBeUndefined(); + }); + + it("returns undefined when Next steps: is the last line with nothing after it", () => { + expect(firstNextStep("Diagnosis: x\nNext steps:")).toBeUndefined(); + }); +});