diff --git a/packages/loopover-mcp/bin/loopover-mcp.ts b/packages/loopover-mcp/bin/loopover-mcp.ts index 8cd38e0609..610b2ba115 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.ts +++ b/packages/loopover-mcp/bin/loopover-mcp.ts @@ -1642,7 +1642,7 @@ function stdioToolDescription(name: any) { /* v8 ignore next 8 -- the CLI dispatch runs only in the launched process (runAsCliEntrypoint); an in-process unit importer keeps it false and drives runCli/maintainCli directly instead (mcp-cli-plan-issues.test.ts). */ -if (runAsCliEntrypoint && cliArgs[0] && cliArgs[0] !== "--stdio") { +if (runAsCliEntrypoint && cliArgs[0] !== "--stdio") { try { const exitCode = await runCli(cliArgs); process.exit(typeof exitCode === "number" ? exitCode : 0); @@ -4102,7 +4102,7 @@ export async function maintainCli(args: any) { async function runCli(args: any) { const command = args[0]; - if (command === "--help" || command === "help") return printHelp(); + if (command === undefined || command === "--help" || command === "help") return printHelp(); if (command === "--version" || command === "-v" || command === "version") return printVersion(parseOptions(args.slice(1))); if (command === "completion") return completionCommand(args.slice(1)); if (command === "tools") return toolsCommand(args.slice(1)); @@ -4970,6 +4970,11 @@ async function runAgentCli(args: any) { // CLI run is invisible to coverage. Same rationale as maintainCli's own export. (#8314) export { runAgentCli }; +// #8313: exported (as a separate statement, same rationale as runAgentCli/maintainCli above) so an in-process +// unit test can drive runCli([]) directly for v8/Codecov coverage of the new `command === undefined` help branch +// -- a bare (zero-arg) invocation is otherwise only reachable via subprocess spawn, which coverage can't see. +export { runCli }; + function outputAgentPayload(payload: any, options: any, summary: any) { if (options.json) { process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); diff --git a/test/unit/mcp-cli-bare-invocation.test.ts b/test/unit/mcp-cli-bare-invocation.test.ts new file mode 100644 index 0000000000..d147e64cc5 --- /dev/null +++ b/test/unit/mcp-cli-bare-invocation.test.ts @@ -0,0 +1,93 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { run } from "./support/mcp-cli-harness"; + +// #8313: a bare `loopover-mcp` (zero arguments) used to fall through the CLI-dispatch gate at +// packages/loopover-mcp/bin/loopover-mcp.ts:1645 (guarded on `cliArgs[0]` being truthy) and reach the +// unconditional StdioServerTransport bind, silently starting the MCP stdio server and hanging on a plain +// terminal. The fix (1) relaxes that entry gate so a zero-arg invocation reaches `runCli([])`, and (2) adds a +// `command === undefined` case to runCli's existing `--help`/`help` branch so bare invocation prints the usage +// banner and exits 0. +// +// The entry-gate line itself only runs in the launched process (runAsCliEntrypoint) and is v8-ignored, so this +// file covers the two observable contracts: the in-process test drives runCli directly so Codecov attributes the +// new `command === undefined` branch (a subprocess spawn is invisible to v8 coverage, per mcp-cli-plan-issues), +// and the subprocess test proves the real end-to-end behavior — bare invocation exits promptly with the banner +// rather than binding stdio and hanging. + +const MODULE = "../../packages/loopover-mcp/bin/loopover-mcp.ts"; + +type BinModule = { + runCli: (args: string[]) => Promise; +}; + +let tempDir = ""; +let bin: BinModule; + +beforeAll(async () => { + tempDir = mkdtempSync(join(tmpdir(), "loopover-bare-invocation-")); + // Keep module load offline/deterministic, matching the other in-process bin importers. + process.env.LOOPOVER_CONFIG_DIR = tempDir; + process.env.LOOPOVER_API_TIMEOUT_MS = "1000"; + process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1"; + bin = (await import(MODULE)) as unknown as BinModule; +}); + +afterAll(() => { + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + delete process.env.LOOPOVER_CONFIG_DIR; + delete process.env.LOOPOVER_API_TIMEOUT_MS; + delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK; +}); + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array): boolean => { + chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }); + try { + await fn(); + } finally { + spy.mockRestore(); + } + return chunks.join(""); +} + +describe("bare loopover-mcp invocation prints usage help instead of starting the stdio server (#8313)", () => { + it("routes runCli([]) (zero args) to the same usage banner as --help and help, in-process", async () => { + // runCli([]) exercises the new `command === undefined` operand; ["--help"] and ["help"] exercise the two + // pre-existing operands of the same branch, so every operand of the changed condition is evaluated true. + const bare = await captureStdout(() => bin.runCli([])); + const dashHelp = await captureStdout(() => bin.runCli(["--help"])); + const help = await captureStdout(() => bin.runCli(["help"])); + + expect(bare).toMatch(/^Usage:/); + expect(bare).toMatch(/loopover-mcp --stdio/); + // A bare invocation must produce byte-identical output to --help and help — that is the contract. + expect(bare).toBe(dashHelp); + expect(bare).toBe(help); + }); + + it("does not divert a real command to help — the new undefined check only matches zero args", async () => { + // Drives the changed condition's false path (`version` is defined and is neither --help nor help), so the + // help branch falls through to normal dispatch instead of printing the usage banner. + const versionOutput = await captureStdout(() => bin.runCli(["version"])); + + expect(versionOutput).toMatch(/@loopover\/mcp\//); + expect(versionOutput).not.toMatch(/^Usage:/); + }); + + it("exits 0 without hanging and prints the same banner as --help when spawned with no arguments", () => { + // execFileSync returns stdout only on exit 0; a non-zero exit or a hang would throw/time out instead. + const bare = run([]); + const dashHelp = run(["--help"]); + + expect(bare).toMatch(/^Usage:/); + expect(bare).toMatch(/loopover-mcp --stdio/); + expect(bare).toBe(dashHelp); + }); +});