Project Retrospectives
Why MCP CLIs fail when stdout is treated like logs
Notes on turning Satori's shell CLI into a disciplined MCP wrapper with stdout safety, dynamic bootstrap, and deterministic exit behavior.
Date
Read
3 minAdding a shell CLI to Satori sounded like a convenience feature. I wanted a way to call the same MCP tools from scripts and non-MCP environments without duplicating the tool logic.
The design constraint was important: MCP remained the source of truth. The CLI should reflect tools/list, call existing tools, and avoid reimplementing behavior outside the server.
The problem under the feature
MCP over stdio is strict. Stdout is protocol. If a normal console.log() leaks into stdout, it can corrupt JSON-RPC traffic.
That made the CLI more than argument parsing. It needed a startup path where diagnostics go to stderr, protocol writes stay isolated, and accidental stdout writes are blocked or redirected in CLI mode.
The failure is easy to create:
Starting Satori...
{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}
That first line looks harmless to a human. To a protocol client, it is garbage in the response stream.
What changed
The server bootstrap had to happen in the right order. Project modules could not be statically imported before stdout safety was installed, because ESM imports run before the importer body. The entrypoint became a small bootstrap that patches console behavior first, then dynamically imports the server start code.
CLI mode also needed different runtime behavior. Background sync and watcher loops are useful in a long-running MCP server, but a one-shot shell call should not start extra loops or kill an indexing operation early. manage_index create and reindex needed wait/poll behavior so the child process did not exit before the lifecycle operation reached a terminal state.
The guard is deliberately low-level because the failure can come from anywhere in the imported graph:
export function installConsoleToStderrPatch() {
for (const method of ["log", "info", "warn", "error", "debug"] as const) {
console[method] = (...args: unknown[]) => {
writeToStderr(formatConsoleArgs(args));
};
}
}
export function installCliStdoutRedirect() {
patch("write", (original, chunk, ...rest) => {
blockChunk(chunk);
original.call(process.stderr, "[STDOUT_BLOCKED] ");
original.call(process.stderr, chunk, ...rest);
return true;
});
}
The real bootstrap installs that protection before importing the server module. That ordering is the feature: by the time project code runs, accidental logs no longer have a path into protocol stdout.
The contract
The CLI became useful only when it behaved like automation infrastructure:
stdout -> machine-readable MCP/tool result
stderr -> diagnostics and progress
exit 0 -> successful tool call
exit 1 -> deterministic non-ok envelope
exit 2 -> usage/configuration problem
The exact exit mapping matters less than the boundary. A shell user can pipe stdout to another tool. A script can branch on exit code. Logs do not poison the protocol result.
The result
The CLI became a thin but disciplined wrapper:
- list tools from the MCP server
- call tools through the MCP client transport
- keep stdout JSON-only
- map non-ok envelopes to automation-friendly exit codes
- preserve MCP as the contract owner
The lesson was that “small tooling” still needs system boundaries. A CLI that corrupts its own protocol output is worse than no CLI at all.
The tradeoff
This is more ceremony than a quick node cli.js search "auth" script. It requires bootstrap discipline, transport setup, and stricter output rules.
But the payoff is consistency. The CLI, MCP server, and automation scripts all exercise the same contracts instead of drifting into separate implementations.