Skip to content

Commit 7a18423

Browse files
fix(mcp): print usage help for bare loopover-mcp invocation instead of starting stdio server
A bare `loopover-mcp` (zero arguments) fell through the CLI-dispatch gate, whose guard required `cliArgs[0]` to be truthy, and reached the unconditional StdioServerTransport bind -- silently starting the MCP stdio server and hanging on a plain terminal that never sends JSON-RPC, instead of printing usage help. Relax the entry gate so a zero-arg invocation reaches `runCli([])`, and add a `command === undefined` case to runCli's existing --help/help branch so it prints the usage banner via printHelp() and exits 0. Only an explicit --stdio invocation still starts the stdio server; --stdio behavior is unchanged. Export runCli (separate statement, mirroring runAgentCli/maintainCli) so a new in-process test drives the changed branch for Codecov coverage; a dedicated regression test also asserts the bare subprocess exits 0 with the banner. Closes #8313
1 parent f1b5cc1 commit 7a18423

2 files changed

Lines changed: 100 additions & 2 deletions

File tree

packages/loopover-mcp/bin/loopover-mcp.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1642,7 +1642,7 @@ function stdioToolDescription(name: any) {
16421642

16431643
/* v8 ignore next 8 -- the CLI dispatch runs only in the launched process (runAsCliEntrypoint); an in-process
16441644
unit importer keeps it false and drives runCli/maintainCli directly instead (mcp-cli-plan-issues.test.ts). */
1645-
if (runAsCliEntrypoint && cliArgs[0] && cliArgs[0] !== "--stdio") {
1645+
if (runAsCliEntrypoint && cliArgs[0] !== "--stdio") {
16461646
try {
16471647
const exitCode = await runCli(cliArgs);
16481648
process.exit(typeof exitCode === "number" ? exitCode : 0);
@@ -4102,7 +4102,7 @@ export async function maintainCli(args: any) {
41024102

41034103
async function runCli(args: any) {
41044104
const command = args[0];
4105-
if (command === "--help" || command === "help") return printHelp();
4105+
if (command === undefined || command === "--help" || command === "help") return printHelp();
41064106
if (command === "--version" || command === "-v" || command === "version") return printVersion(parseOptions(args.slice(1)));
41074107
if (command === "completion") return completionCommand(args.slice(1));
41084108
if (command === "tools") return toolsCommand(args.slice(1));
@@ -4970,6 +4970,11 @@ async function runAgentCli(args: any) {
49704970
// CLI run is invisible to coverage. Same rationale as maintainCli's own export. (#8314)
49714971
export { runAgentCli };
49724972

4973+
// #8313: exported (as a separate statement, same rationale as runAgentCli/maintainCli above) so an in-process
4974+
// unit test can drive runCli([]) directly for v8/Codecov coverage of the new `command === undefined` help branch
4975+
// -- a bare (zero-arg) invocation is otherwise only reachable via subprocess spawn, which coverage can't see.
4976+
export { runCli };
4977+
49734978
function outputAgentPayload(payload: any, options: any, summary: any) {
49744979
if (options.json) {
49754980
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { mkdtempSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
5+
6+
import { run } from "./support/mcp-cli-harness";
7+
8+
// #8313: a bare `loopover-mcp` (zero arguments) used to fall through the CLI-dispatch gate at
9+
// packages/loopover-mcp/bin/loopover-mcp.ts:1645 (guarded on `cliArgs[0]` being truthy) and reach the
10+
// unconditional StdioServerTransport bind, silently starting the MCP stdio server and hanging on a plain
11+
// terminal. The fix (1) relaxes that entry gate so a zero-arg invocation reaches `runCli([])`, and (2) adds a
12+
// `command === undefined` case to runCli's existing `--help`/`help` branch so bare invocation prints the usage
13+
// banner and exits 0.
14+
//
15+
// The entry-gate line itself only runs in the launched process (runAsCliEntrypoint) and is v8-ignored, so this
16+
// file covers the two observable contracts: the in-process test drives runCli directly so Codecov attributes the
17+
// new `command === undefined` branch (a subprocess spawn is invisible to v8 coverage, per mcp-cli-plan-issues),
18+
// and the subprocess test proves the real end-to-end behavior — bare invocation exits promptly with the banner
19+
// rather than binding stdio and hanging.
20+
21+
const MODULE = "../../packages/loopover-mcp/bin/loopover-mcp.ts";
22+
23+
type BinModule = {
24+
runCli: (args: string[]) => Promise<number | void>;
25+
};
26+
27+
let tempDir = "";
28+
let bin: BinModule;
29+
30+
beforeAll(async () => {
31+
tempDir = mkdtempSync(join(tmpdir(), "loopover-bare-invocation-"));
32+
// Keep module load offline/deterministic, matching the other in-process bin importers.
33+
process.env.LOOPOVER_CONFIG_DIR = tempDir;
34+
process.env.LOOPOVER_API_TIMEOUT_MS = "1000";
35+
process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK = "1";
36+
bin = (await import(MODULE)) as unknown as BinModule;
37+
});
38+
39+
afterAll(() => {
40+
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
41+
delete process.env.LOOPOVER_CONFIG_DIR;
42+
delete process.env.LOOPOVER_API_TIMEOUT_MS;
43+
delete process.env.LOOPOVER_SKIP_NPM_VERSION_CHECK;
44+
});
45+
46+
async function captureStdout(fn: () => Promise<number | void>): Promise<string> {
47+
const chunks: string[] = [];
48+
const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array): boolean => {
49+
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
50+
return true;
51+
});
52+
try {
53+
await fn();
54+
} finally {
55+
spy.mockRestore();
56+
}
57+
return chunks.join("");
58+
}
59+
60+
describe("bare loopover-mcp invocation prints usage help instead of starting the stdio server (#8313)", () => {
61+
it("routes runCli([]) (zero args) to the same usage banner as --help and help, in-process", async () => {
62+
// runCli([]) exercises the new `command === undefined` operand; ["--help"] and ["help"] exercise the two
63+
// pre-existing operands of the same branch, so every operand of the changed condition is evaluated true.
64+
const bare = await captureStdout(() => bin.runCli([]));
65+
const dashHelp = await captureStdout(() => bin.runCli(["--help"]));
66+
const help = await captureStdout(() => bin.runCli(["help"]));
67+
68+
expect(bare).toMatch(/^Usage:/);
69+
expect(bare).toMatch(/loopover-mcp --stdio/);
70+
// A bare invocation must produce byte-identical output to --help and help — that is the contract.
71+
expect(bare).toBe(dashHelp);
72+
expect(bare).toBe(help);
73+
});
74+
75+
it("does not divert a real command to help — the new undefined check only matches zero args", async () => {
76+
// Drives the changed condition's false path (`version` is defined and is neither --help nor help), so the
77+
// help branch falls through to normal dispatch instead of printing the usage banner.
78+
const versionOutput = await captureStdout(() => bin.runCli(["version"]));
79+
80+
expect(versionOutput).toMatch(/@loopover\/mcp\//);
81+
expect(versionOutput).not.toMatch(/^Usage:/);
82+
});
83+
84+
it("exits 0 without hanging and prints the same banner as --help when spawned with no arguments", () => {
85+
// execFileSync returns stdout only on exit 0; a non-zero exit or a hang would throw/time out instead.
86+
const bare = run([]);
87+
const dashHelp = run(["--help"]);
88+
89+
expect(bare).toMatch(/^Usage:/);
90+
expect(bare).toMatch(/loopover-mcp --stdio/);
91+
expect(bare).toBe(dashHelp);
92+
});
93+
});

0 commit comments

Comments
 (0)