Skip to content
Ahmed Hamza

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 min

Adding 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:

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.