diff --git a/src/mcpDoctor.ts b/src/mcpDoctor.ts index f6c8510bf..0f5de39fc 100644 --- a/src/mcpDoctor.ts +++ b/src/mcpDoctor.ts @@ -1,6 +1,6 @@ import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; -import { Formatter } from "incur"; +import { Formatter, Help, z } from "incur"; import { resolve } from "node:path"; import { CATALOG_IDENTITY } from "./catalogIdentity.js"; @@ -11,17 +11,42 @@ import { MCP_STARTUP_POLICY } from "./mcpStartupPolicy.js"; const OUTPUT_FORMATS = ["toon", "json", "yaml", "md", "jsonl"] as const; type OutputFormat = (typeof OUTPUT_FORMATS)[number]; -const MCP_DOCTOR_HELP = `Usage: rea mcp doctor [options] -Validate MCP server startup, tool and prompt inventories, identity, and request flow. +/** + * Canonical `rea mcp doctor` options. This schema is the single source of + * truth: the argument parser accepts exactly these options (plus the `-h` + * alias below) and the help renderer lists them. Keep them in sync by + * changing this schema, not either consumer. + */ +const MCP_DOCTOR_OPTIONS = z.object({ + format: z.enum(OUTPUT_FORMATS).optional().describe("Output format"), + json: z.boolean().optional().describe("Emit the report as JSON"), + fullOutput: z.boolean().optional().describe("Show the full output envelope"), + help: z.boolean().optional().describe("Show help"), +}); -Options: - --json Output JSON - --format Output format: ${OUTPUT_FORMATS.join(", ")} - --format= Set the output format - --full-output Include complete diagnostic details - -h, --help Show this help -`; +const MCP_DOCTOR_ALIAS = { help: "h" } as const; + +const MCP_DOCTOR_COMMAND = "rea mcp doctor"; +const MCP_DOCTOR_DESCRIPTION = "Validate MCP server startup and tool listing"; + +/** Help flags derived from `MCP_DOCTOR_OPTIONS` plus its alias. */ +const HELP_FLAGS: ReadonlySet = new Set(["--help", "-h"]); + +/** + * Answer an advertised help request without starting a diagnostic session. + * Global options are hidden because this dispatcher entry accepts only the + * options above; the hint says so explicitly instead of implying full + * Incur parity. + */ +const renderMcpDoctorHelp = (): string => + Help.formatCommand(MCP_DOCTOR_COMMAND, { + description: MCP_DOCTOR_DESCRIPTION, + hideGlobalOptions: true, + options: MCP_DOCTOR_OPTIONS, + alias: MCP_DOCTOR_ALIAS, + hint: "This entry accepts only the options above.", + }); interface McpDoctorOptions { readonly command: string; @@ -183,10 +208,10 @@ export const runProductionMcpDoctorCli = async ( arguments_: readonly string[], input: { readonly dispatcherPath: string; readonly packageRoot: string }, ): Promise<{ readonly output: string; readonly exitCode: number }> => { - if (arguments_.includes("--help") || arguments_.includes("-h")) - return { output: MCP_DOCTOR_HELP, exitCode: 0 }; const parsed = parseOutputArguments(arguments_); - if (!parsed.ok) + if (!parsed.ok) { + if (parsed.help) + return { output: `${renderMcpDoctorHelp()}\n`, exitCode: 0 }; return { output: `${Formatter.format( { code: "VALIDATION_ERROR", message: parsed.message }, @@ -194,6 +219,7 @@ export const runProductionMcpDoctorCli = async ( )}\n`, exitCode: 1, }; + } const result = await runProductionMcpDoctor({ command: process.execPath, args: [resolve(input.dispatcherPath), "mcp"], @@ -265,15 +291,29 @@ const parseIdentityToolResult = ( }; }; -const parseOutputArguments = ( - arguments_: readonly string[], -): +/** + * Parsed `rea mcp doctor` arguments, including a help request. The `help` + * variant is the only success-independent early return; every other accepted + * flag is an output selector declared in `MCP_DOCTOR_OPTIONS`. + */ +type McpDoctorArguments = | { readonly ok: true; readonly format: OutputFormat } + | { readonly ok: false; readonly help: true } | { readonly ok: false; + readonly help: false; readonly format: OutputFormat; readonly message: string; - } => { + }; + +const parseOutputArguments = ( + arguments_: readonly string[], +): McpDoctorArguments => { + // Help wins over output selection and validation errors, but only when it + // appears in option position. A token consumed as `--format`'s value is + // not a flag, so `--format --help` reports an invalid format instead of + // masking the typo with usage. + if (hasHelpInOptionPosition(arguments_)) return { ok: false, help: true }; let format: OutputFormat = "toon"; for (let index = 0; index < arguments_.length; index += 1) { const argument = arguments_[index]; @@ -290,6 +330,7 @@ const parseOutputArguments = ( if (!isOutputFormat(candidate)) return { ok: false, + help: false, format, message: `Invalid output format: ${candidate ?? "missing"}`, }; @@ -297,8 +338,10 @@ const parseOutputArguments = ( continue; } if (argument === "--full-output") continue; + if (HELP_FLAGS.has(argument ?? "")) return { ok: false, help: true }; return { ok: false, + help: false, format, message: `Unknown mcp doctor option: ${argument ?? "missing"}`, }; @@ -306,6 +349,23 @@ const parseOutputArguments = ( return { ok: true, format }; }; +/** + * Report whether `--help`/`-h` appears in option position. Tokens consumed + * as `--format`'s value are skipped so `--format --help` stays an invalid + * format error instead of silently becoming a help request. + */ +const hasHelpInOptionPosition = (arguments_: readonly string[]): boolean => { + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument === "--format") { + index += 1; + continue; + } + if (HELP_FLAGS.has(argument ?? "")) return true; + } + return false; +}; + const isOutputFormat = (value: string | undefined): value is OutputFormat => OUTPUT_FORMATS.some((candidate) => candidate === value); diff --git a/tests/boundary/cli/mcpDoctorHelp.test.ts b/tests/boundary/cli/mcpDoctorHelp.test.ts index e619d819d..d3b2e55b7 100644 --- a/tests/boundary/cli/mcpDoctorHelp.test.ts +++ b/tests/boundary/cli/mcpDoctorHelp.test.ts @@ -45,7 +45,7 @@ it("shows MCP doctor help without starting a server and keeps option errors acti const help = await runCli(["mcp", "doctor", "--help"]); expect(help.exitCode).toBe(0); expect(help.stdout).toContain("Usage: rea mcp doctor [options]"); - expect(help.stdout).toContain("--format "); + expect(help.stdout).toContain("--format "); expect(help.stdout).toContain("--full-output"); const invalid = await runCli(["mcp", "doctor", "--not-an-option"]); diff --git a/tests/boundary/cli/mcpDoctorHelpCli.test.ts b/tests/boundary/cli/mcpDoctorHelpCli.test.ts new file mode 100644 index 000000000..4888e363e --- /dev/null +++ b/tests/boundary/cli/mcpDoctorHelpCli.test.ts @@ -0,0 +1,96 @@ +import { describe, expect } from "vitest"; + +import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; + +const CLI_INTEGRATION_TIMEOUT_MS = 60_000; + +const EXPECTED_HELP = [ + "rea mcp doctor — Validate MCP server startup and tool listing", + "", + "Usage: rea mcp doctor [options]", + "", + "Options:", + " --format Output format", + " --json Emit the report as JSON", + " --full-output Show the full output envelope", + " --help, -h Show help", + "", + "This entry accepts only the options above.", + "", +].join("\n"); + +describe("MCP doctor help boundary", () => { + workspaceCliTest( + "answers the advertised help option instead of validating it", + async ({ cli }) => { + for (const argument of ["--help", "-h"]) { + const result = await cli.run({ + arguments: ["mcp", "doctor", argument], + }); + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toBe(EXPECTED_HELP); + // Help returns before the diagnostic session starts, so no report + // envelope and no startup diagnostics are produced. + expect(result.stdout).not.toContain("healthy"); + expect(result.stderr).toBe(""); + } + }, + CLI_INTEGRATION_TIMEOUT_MS, + ); + + workspaceCliTest( + "answers help in option position regardless of surrounding output options", + async ({ cli }) => { + for (const arguments_ of [ + ["mcp", "doctor", "--json", "--help"], + ["mcp", "doctor", "--format", "bogus", "--help"], + ["mcp", "doctor", "--help", "--format", "json"], + ]) { + const result = await cli.run({ arguments: arguments_ }); + expect(result).toMatchObject({ exitCode: 0 }); + expect(result.stdout).toContain("Usage: rea mcp doctor [options]"); + expect(result.stdout).toContain("--help, -h"); + expect(result.stdout).not.toContain("healthy"); + } + }, + CLI_INTEGRATION_TIMEOUT_MS, + ); + + workspaceCliTest( + "treats help consumed as --format value as an invalid format, not help", + async ({ cli }) => { + for (const arguments_ of [ + ["mcp", "doctor", "--format", "--help"], + ["mcp", "doctor", "--format", "-h"], + ["mcp", "doctor", "--format=--help"], + ]) { + const result = await cli.run({ arguments: arguments_ }); + expect(result).toMatchObject({ exitCode: 1 }); + expect(result.stdout).toContain("Invalid output format:"); + expect(result.stdout).not.toContain("Usage: rea mcp doctor"); + } + }, + CLI_INTEGRATION_TIMEOUT_MS, + ); + + workspaceCliTest( + "keeps the actionable error for a genuinely unknown option", + async ({ cli }) => { + const result = await cli.run({ arguments: ["mcp", "doctor", "--bogus"] }); + expect(result).toMatchObject({ + exitCode: 1, + stdout: + 'code: VALIDATION_ERROR\nmessage: "Unknown mcp doctor option: --bogus"\n', + }); + const invalid = await cli.run({ + arguments: ["mcp", "doctor", "--format", "bogus"], + }); + expect(invalid).toMatchObject({ + exitCode: 1, + stdout: + 'code: VALIDATION_ERROR\nmessage: "Invalid output format: bogus"\n', + }); + }, + CLI_INTEGRATION_TIMEOUT_MS, + ); +});