fix(cli): drive mcp doctor help from a single option schema
Parses rea mcp doctor arguments against one zod option schema that is also the help renderer's source of truth, answering --help/-h in any position without starting a diagnostic session and keeping actionable errors for unknown options and invalid formats. Updates the landed help assertion to the schema-rendered format list and adds boundary coverage for help positioning and --format edge cases.
This commit is contained in:
parent
5ac3dc96f0
commit
b7d803dd10
|
|
@ -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 <format> Output format: ${OUTPUT_FORMATS.join(", ")}
|
||||
--format=<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<string> = 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <format>");
|
||||
expect(help.stdout).toContain("--format <toon|json|yaml|md|jsonl>");
|
||||
expect(help.stdout).toContain("--full-output");
|
||||
|
||||
const invalid = await runCli(["mcp", "doctor", "--not-an-option"]);
|
||||
|
|
|
|||
|
|
@ -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 <toon|json|yaml|md|jsonl> 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,
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue