diff --git a/scripts/rea.mjs b/scripts/rea.mjs index 321c801aad..cde17dde0e 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -76,6 +76,7 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { const result = await runProductionMcpDoctorCli(args.slice(2), { dispatcherPath: fileURLToPath(import.meta.url), packageRoot, + stdoutIsTerminal: process.stdout.isTTY === true, }); writeStdout(result.output); process.exitCode = result.exitCode; diff --git a/src/mcpDoctor.ts b/src/mcpDoctor.ts index a2b087914a..4cfd927a1b 100644 --- a/src/mcpDoctor.ts +++ b/src/mcpDoctor.ts @@ -6,6 +6,7 @@ import { resolve } from "node:path"; import { StringDecoder } from "node:string_decoder"; import { createCatalogIdentity } from "./catalogIdentity.js"; +import { jsonOutputParts } from "./cli/jsonOutput.js"; import { compactPresentationForMcpInputSchemaProfile, parseMcpInputSchemaProfile, @@ -324,7 +325,11 @@ const inspectProductionMcpSession = async ( */ export const runProductionMcpDoctorCli = async ( arguments_: readonly string[], - input: { readonly dispatcherPath: string; readonly packageRoot: string }, + input: { + readonly dispatcherPath: string; + readonly packageRoot: string; + readonly stdoutIsTerminal: boolean; + }, ): Promise<{ readonly output: string; readonly exitCode: number }> => { const started = performance.now(); const parsed = parseOutputArguments(arguments_); @@ -332,10 +337,11 @@ export const runProductionMcpDoctorCli = async ( if (parsed.help) return { output: `${renderMcpDoctorHelp()}\n`, exitCode: 0 }; return { - output: `${Formatter.format( + output: renderOutput( { code: "VALIDATION_ERROR", message: parsed.message }, parsed.format, - )}\n`, + input.stdoutIsTerminal, + ), exitCode: 1, }; } @@ -356,7 +362,7 @@ export const runProductionMcpDoctorCli = async ( } : result; return { - output: `${Formatter.format(outputValue, parsed.format)}\n`, + output: renderOutput(outputValue, parsed.format, input.stdoutIsTerminal), exitCode: result.healthy ? 0 : 1, }; }; @@ -439,6 +445,15 @@ type McpDoctorArguments = readonly message: string; }; +const renderOutput = ( + value: unknown, + format: OutputFormat, + stdoutIsTerminal: boolean, +): string => + format === "json" || format === "jsonl" + ? [...jsonOutputParts(value, format, stdoutIsTerminal)].join("") + : `${Formatter.format(value, format)}\n`; + const parseOutputArguments = ( arguments_: readonly string[], ): McpDoctorArguments => { diff --git a/tests/boundary/cli/dispatcher.test.ts b/tests/boundary/cli/dispatcher.test.ts index 40b46736de..621c5d98fc 100644 --- a/tests/boundary/cli/dispatcher.test.ts +++ b/tests/boundary/cli/dispatcher.test.ts @@ -79,7 +79,7 @@ describe("executable dispatcher", () => { ), workspace.write( "dist/cli-runtime/mcpDoctor.js", - 'export const runProductionMcpDoctorCli = (args) => Promise.resolve({ output: JSON.stringify({ adapter: "mcp-doctor", args }), exitCode: 0 });\n', + 'export const runProductionMcpDoctorCli = (args, input) => Promise.resolve({ output: JSON.stringify({ adapter: "mcp-doctor", args, stdoutIsTerminal: input.stdoutIsTerminal }), exitCode: 0 });\n', ), ]); @@ -98,6 +98,7 @@ describe("executable dispatcher", () => { await expect(invoke(["mcp", "doctor", "--json"])).resolves.toEqual({ adapter: "mcp-doctor", args: ["--json"], + stdoutIsTerminal: false, }); await expect(invoke(["mcp", "add"])).resolves.toEqual({ adapter: "cli", @@ -120,6 +121,7 @@ describe("executable dispatcher", () => { await expect(invoke(["mcp", "doctor", "--http"])).resolves.toEqual({ adapter: "mcp-doctor", args: ["--http"], + stdoutIsTerminal: false, }); await expect(invoke(["mcp", "add", "--http"])).resolves.toEqual({ adapter: "cli", diff --git a/tests/boundary/mcp/mcpDoctor.test.ts b/tests/boundary/mcp/mcpDoctor.test.ts index c6a4ccfd4c..83b44dadbf 100644 --- a/tests/boundary/mcp/mcpDoctor.test.ts +++ b/tests/boundary/mcp/mcpDoctor.test.ts @@ -83,6 +83,7 @@ describe("production MCP doctor", () => { { dispatcherPath: resolve("tests/fixtures/mcpDoctorExit.mjs"), packageRoot: process.cwd(), + stdoutIsTerminal: false, }, ); expect(result.exitCode).toBe(1); @@ -191,6 +192,38 @@ describe("production MCP doctor", () => { ); }); +describe("production MCP doctor output", () => { + it.each([ + { name: "report", args: ["--json"] }, + { name: "full-output envelope", args: ["--json", "--full-output"] }, + { name: "validation error", args: ["--json", "--unknown"] }, + ])("indents $name JSON only for a terminal stdout", async ({ args }) => { + const render = (stdoutIsTerminal: boolean) => + runProductionMcpDoctorCli(args, { + dispatcherPath: resolve("tests/fixtures/mcpDoctorExit.mjs"), + packageRoot: process.cwd(), + stdoutIsTerminal, + }); + const [redirected, terminal] = await Promise.all([ + render(false), + render(true), + ]); + + expect(redirected.output.split("\n")).toHaveLength(2); + expect(redirected.output.endsWith("\n")).toBe(true); + expect(terminal.output).toContain('\n "'); + const withoutDuration = (output: string): unknown => { + const value = JSON.parse(output) as { meta?: { duration?: string } }; + if (value.meta) delete value.meta.duration; + return value; + }; + expect(withoutDuration(redirected.output)).toEqual( + withoutDuration(terminal.output), + ); + expect(redirected.exitCode).toBe(terminal.exitCode); + }); +}); + describe("production MCP doctor probe validation", () => { it("rejects a schema-invalid probe while retaining the discovered inventory", async () => { const root = await createTestTempDirectory("rea-mcp-doctor-reply-");