Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/commands/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <server-package>";
}
if (lower.includes("enoent") || lower.includes("spawn")) {
return "Check that the command is installed and on PATH. Try: npx -y <server-package>";
}
if (lower.includes("unexpected token") || lower.includes("invalid config") || lower.includes("is not valid json")) {
return "Check target JSON format. Run: cat <config-file>";
}
return undefined;
}

export function colorStatus(status: string): string {
switch (status) {
case "pass":
Expand Down
12 changes: 11 additions & 1 deletion src/commands/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────

Expand Down Expand Up @@ -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`);
}
Expand Down Expand Up @@ -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 ")) {
Expand Down
11 changes: 10 additions & 1 deletion src/commands/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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`);
Expand Down
14 changes: 14 additions & 0 deletions src/utils/failure-diagnosis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
33 changes: 33 additions & 0 deletions tests/commands-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
setNoColor,
setQuiet,
setupCiHint,
suggestFix,
targetFromCommand,
useColor,
writeOutput,
Expand Down Expand Up @@ -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 <server-package>",
);
});

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 <server-package>",
);
});

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 <config-file>",
);
});

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");
Expand Down
28 changes: 28 additions & 0 deletions tests/failure-diagnosis.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading