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
9 changes: 7 additions & 2 deletions packages/loopover-mcp/bin/loopover-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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`);
Expand Down
93 changes: 93 additions & 0 deletions test/unit/mcp-cli-bare-invocation.test.ts
Original file line number Diff line number Diff line change
@@ -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<number | void>;
};

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<number | void>): Promise<string> {
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);
});
});