You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Give the CLI a stable machine-readable failure surface: an exit-code map (0 ok · 1 usage · 2 no-app · 3 auth-required · 4 unreachable · 5 tool-error) and a one-line JSON ErrorEnvelope ({"error":{code,message,cause,status,url}}) on stderr for every non-zero exit.
Why
A programmatic driver (CI, scripts, agents) needs to branch on why the CLI failed without scraping prose stderr. This is the foundation for the whole CLI lane of the PR #1510 decomposition — --app-info (exit 2), stored-auth (exit 3), and --format json all build on it.
Verified on branch v2/1564-cli-exit-codes (HEAD fa5514a3) against base v2/main.
Smoke test
Method: Built the CLI (clients/cli/build/index.js) and the bundled stdio test server, then drove the real built binary end-to-end with a small Node script (no mocks). Cases: a success path over stdio, two usage errors, an unreachable host (bad DNS over HTTP), a tool-call to a nonexistent tool, and an auth path against a local HTTP server that answers 401 with WWW-Authenticate. For each case the script captures the process exit code and the single JSON error envelope line on stderr.
### SUCCESS: tools/list (stdio test server)
exit code : 0
envelope : (no JSON envelope on stderr)
stdout[0] : { (tools JSON printed)
### USAGE ERROR: missing --method
exit code : 1
envelope : {"error":{"code":"error","message":"Method is required. Use --method to specify the method to invoke."}}
### USAGE ERROR: unsupported method foo/bar
exit code : 1
envelope : {"error":{"code":"error","message":"Unsupported method: foo/bar. Supported methods include: tools/list, tools/call, resources/list, resources/read, resources/templates/list, prompts/list, prompts/get, logging/setLevel"}}
### UNREACHABLE: bad DNS host (http)
exit code : 4
envelope : {"error":{"code":"unreachable","message":"fetch failed","cause":"getaddrinfo ENOTFOUND does-not-exist.invalid"}}
### TOOL: nonexistent tool name
exit code : 0
envelope : (no JSON envelope on stderr)
note : server returns isError:true content on stdout (exit 0)
### AUTH REQUIRED: server returns 401
exit code : 3
envelope : {"error":{"code":"auth_required","message":"Streamable HTTP error: Error POSTing to endpoint: {\"error\":\"invalid_token\"}","status":401}}
Result: Exit codes 0 (OK), 1 (USAGE), 4 (UNREACHABLE), and 3 (AUTH_REQUIRED) all fire correctly, each with a well-formed single-line {"error":{…}} envelope — including the enriched cause (undici ENOTFOUND) on the unreachable case and status: 401 on the auth case. Classification works as designed.
Scope note (not a defect):EXIT_CODES.TOOL_ERROR (5) and EXIT_CODES.NO_APP (2) are defined in the map and covered by codeForExit/classifyError, but there is no CliExitCodeError throw site in cli.ts yet for them. A tools/call to an unknown tool returns an isError:true result on stdout and exits 0 (see the TOOL case above). This PR lands the exit-code map + envelope infrastructure; wiring codes 2 and 5 to their throw sites is follow-up work.
AGENTS.md compliance
Mantine idioms: N/A — this is the CLI, no React/Mantine surface.
TypeScript: PASS. git diff origin/v2/main...HEAD adds no any (no : any, as any, <any>), and no eslint-disable / @ts-ignore / @ts-expect-error / rule-suppression comments. Error shapes are narrowed via typed guards (statusOf, causeOf).
(src/cli.ts also clears the gate: 100 / 91.13 / 100 / 100.)
Verdict: PASS
Exit-code map and JSON error envelopes behave correctly end-to-end on the real binary; TypeScript and the per-file coverage gate both pass. The only observation is a scope note (codes 2/5 defined but not yet emitted), which is consistent with this PR being the infrastructure layer.
Custom smoke-test script
#!/usr/bin/env node
/** * Smoke test for PR #1583 — CLI exit-code map + structured JSON error envelopes. * Drives the BUILT cli binary (clients/cli/build/index.js) against the bundled * stdio test server (success + tool paths), a bad DNS host (unreachable), and a * local HTTP server that answers 401 (auth). Prints, for each case: * case -> exit code -> single JSON stderr envelope line */import{spawn}from"node:child_process";import{mkdtempSync,writeFileSync}from"node:fs";import{tmpdir}from"node:os";import{join}from"node:path";importhttpfrom"node:http";constrepoRoot=process.argv[2];constcli=join(repoRoot,"clients","cli","build","index.js");conststdioServer=join(repoRoot,"test-servers","build","test-server-stdio.js");constwork=mkdtempSync(join(tmpdir(),"smoke-exit-"));constcatalogPath=join(work,"catalog.json");writeFileSync(catalogPath,JSON.stringify({mcpServers: {test: {type: "stdio",command: process.execPath,args: [stdioServer]},},}),);functionrun(args){returnnewPromise((res)=>{constchild=spawn(process.execPath,[cli, ...args],{cwd: repoRoot,env: { ...process.env,HOME: work,USERPROFILE: work},});letstdout="",stderr="";child.stdout.setEncoding("utf-8");child.stderr.setEncoding("utf-8");child.stdout.on("data",(d)=>(stdout+=d));child.stderr.on("data",(d)=>(stderr+=d));child.on("close",(status)=>res({ status, stdout, stderr }));});}functionenvelopeLine(stderr){constlines=stderr.trim().split("\n").filter(Boolean);for(leti=lines.length-1;i>=0;i--){constl=lines[i].trim();if(l.startsWith("{")&&l.includes('"error"'))returnl;}return"(no JSON envelope on stderr)";}functionreport(name,r,note){console.log(`\n### ${name}`);console.log(`exit code : ${r.status}`);console.log(`envelope : ${envelopeLine(r.stderr)}`);if(note)console.log(`note : ${note}`);constfirstStdout=r.stdout.trim().split("\n")[0]??"";if(firstStdout)console.log(`stdout[0] : ${firstStdout.slice(0,120)}`);}functionstart401(){returnnewPromise((res)=>{constsrv=http.createServer((req,resp)=>{resp.writeHead(401,{"WWW-Authenticate": 'Bearer realm="mcp", error="invalid_token"',"Content-Type": "application/json",});resp.end(JSON.stringify({error: "invalid_token"}));});srv.listen(0,"127.0.0.1",()=>res(srv));});}constbase=["--catalog",catalogPath,"--server","test"];report("SUCCESS: tools/list (stdio test server)",awaitrun([...base,"--method","tools/list"]));report("USAGE ERROR: missing --method",awaitrun([...base]));report("USAGE ERROR: unsupported method foo/bar",awaitrun([...base,"--method","foo/bar"]));report("UNREACHABLE: bad DNS host (http)",awaitrun(["--transport","http","--server-url","http://does-not-exist.invalid/mcp","--method","tools/list",]));report("TOOL: nonexistent tool name",awaitrun([
...base,"--method","tools/call","--tool-name","no_such_tool",]));constsrv=awaitstart401();constport=srv.address().port;report("AUTH REQUIRED: server returns 401",awaitrun(["--transport","http","--server-url",`http://127.0.0.1:${port}/mcp`,"--method","tools/list",]));srv.close();
🔗 Implemented by PR #1583. Its code is already integrated into the Wave 1 rollup branch 1579-wave-1 (see rollup PR #1601 / verification #1600). #1583 currently targets v2/main and will auto-show as Merged once the rollup lands in v2/main. The Development panel doesn't link it now because the closing keyword is on a non-default base branch.
Activity
cliffhall commentedon Jul 1, 2026
Smoke test & AGENTS.md audit — PR #1583
Verified on branch
v2/1564-cli-exit-codes(HEADfa5514a3) against basev2/main.Smoke test
Method: Built the CLI (
clients/cli/build/index.js) and the bundled stdio test server, then drove the real built binary end-to-end with a small Node script (no mocks). Cases: a success path over stdio, two usage errors, an unreachable host (bad DNS over HTTP), a tool-call to a nonexistent tool, and an auth path against a local HTTP server that answers401withWWW-Authenticate. For each case the script captures the process exit code and the single JSON error envelope line on stderr.Result: Exit codes
0(OK),1(USAGE),4(UNREACHABLE), and3(AUTH_REQUIRED) all fire correctly, each with a well-formed single-line{"error":{…}}envelope — including the enrichedcause(undiciENOTFOUND) on the unreachable case andstatus: 401on the auth case. Classification works as designed.Scope note (not a defect):
EXIT_CODES.TOOL_ERROR (5)andEXIT_CODES.NO_APP (2)are defined in the map and covered bycodeForExit/classifyError, but there is noCliExitCodeErrorthrow site incli.tsyet for them. Atools/callto an unknown tool returns anisError:trueresult on stdout and exits0(see the TOOL case above). This PR lands the exit-code map + envelope infrastructure; wiring codes 2 and 5 to their throw sites is follow-up work.AGENTS.md compliance
Mantine idioms: N/A — this is the CLI, no React/Mantine surface.
TypeScript: PASS.
git diff origin/v2/main...HEADadds noany(no: any,as any,<any>), and noeslint-disable/@ts-ignore/@ts-expect-error/ rule-suppression comments. Error shapes are narrowed via typed guards (statusOf,causeOf).Coverage gate (
cd clients/cli && npm run test:coverage): PASS. 129 tests pass.src/error-handler.ts:(
src/cli.tsalso clears the gate: 100 / 91.13 / 100 / 100.)Verdict: PASS
Exit-code map and JSON error envelopes behave correctly end-to-end on the real binary; TypeScript and the per-file coverage gate both pass. The only observation is a scope note (codes 2/5 defined but not yet emitted), which is consistent with this PR being the infrastructure layer.
Custom smoke-test script
cliffhall commentedon Jul 2, 2026
🔗 Implemented by PR #1583. Its code is already integrated into the Wave 1 rollup branch
1579-wave-1(see rollup PR #1601 / verification #1600). #1583 currently targetsv2/mainand will auto-show as Merged once the rollup lands inv2/main. The Development panel doesn't link it now because the closing keyword is on a non-default base branch.feat(cli): exit-code map + structured JSON error envelopes (closes mo…