A stdio MCP server has two output streams and they are not interchangeable. The client reads stdout as the protocol — newline-delimited JSON-RPC, one message per line — and nothing else may appear there. stderr is the server’s own: the client may log it, show it, or drop it, but never parses it. Print a greeting to stdout, or log to it, and the server may work perfectly by hand while the client cannot understand a word. This lab’s server did both.
The stdio transport frames messages by newline. The client reads a line, parses it as JSON, and expects a JSON-RPC message: a response to something it sent, a request, or a notification. The specification says a server must not write anything to stdout that is not a valid message. How a client treats a line that breaks the rule is up to the client: Claude Code 2.1.270, as the walkthrough shows, skips a line that is not JSON — but it cannot recover a message that shares its line with something else.
The wrapper /usr/local/bin/metrics-mcp prints metrics-mcp 3.1 ready — without a newline — before
it hands over to Python. The first thing the server writes is its answer to initialize, so the answer
becomes metrics-mcp 3.1 ready {"jsonrpc": …}: one line that is not JSON. The client never sees the
answer to its first request, waits, and gives up. That is fatal.
The debug log ("log_to": "stdout") writes a line after every reply. Each is a line of its own, so a
tolerant client skips them and carries on: harmless to Claude Code today, a protocol violation all the
same, noise in every exchange, and fatal to a stricter client — or to this one if a log line is ever
written without its newline.
stderr, or a file. Claude Code keeps a stdio server’s stderr for its debug output; a file survives
restarts and can be rotated. The one wrong answer is log_level: off: the log was turned on to chase a
slow answer, and whoever turned it on still needs it.
Whatever .mcp.json names as the command is the server, as far as the client is concerned: a shell
wrapper, npx, uvx, a docker run. Anything any of them prints to stdout reaches the client. Package
managers that announce what they install are a classic source; a wrapper’s echo is another.
Replayed on the lab’s container (Ubuntu 26.04, Claude Code 2.1.270; JSON files shown on one line). The command the project runs, the wrapper, and the server’s settings:
$ cat ~/ops/.mcp.json; cat /usr/local/bin/metrics-mcp; cat /etc/metrics-mcp/config.json
{"mcpServers": {"metrics": {"type": "stdio", "command": "/usr/local/bin/metrics-mcp"}}}
#!/bin/sh
# Start the metrics MCP server. Kept as a wrapper so the version shows when it starts.
printf 'metrics-mcp 3.1 ready '
exec python3 /opt/metrics-mcp/metrics_mcp.py "$@"
{"log_level": "debug", "log_to": "stdout"}
The server by hand, stderr thrown away — so this is exactly what a client reads:
$ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | /usr/local/bin/metrics-mcp 2>/dev/null
metrics-mcp 3.1 ready {"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", ...}}
18:57:49 DEBUG metrics: handled initialize (id 1), pid 90
{"jsonrpc": "2.0", "id": 2, "result": {"tools": [{"name": "get_load", ...}]}}
18:57:49 DEBUG metrics: handled tools/list (id 2), pid 90
It works, to a person. The job, with a model that asks for get_load, sees:
mcp tools offered: []
get_load: <tool_use_error>Error: No such tool available: mcp__metrics__get_load. The MCP server
'metrics' is still connecting. Call WaitForMcpServers to wait for it, then try again.</tool_use_error>
Send the banner to stderr (printf 'metrics-mcp 3.1 ready\n' >&2) and nothing else, and the job
connects — the debug lines are still on stdout, and Claude Code skips them:
mcp tools offered: ['mcp__metrics__get_load']
get_load: load 2.46 2.38 2.28; disk / 55% used; cpus 8
$ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | /usr/local/bin/metrics-mcp 2>/dev/null
{"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", ...}}
18:58:07 DEBUG metrics: handled initialize (id 1), pid 164
Then "log_to": "stderr": stdout carries the protocol and nothing else, and the log is where it
belongs:
$ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | /usr/local/bin/metrics-mcp 2>/tmp/err.txt
{"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", ...}}
$ cat /tmp/err.txt
metrics-mcp 3.1 ready
18:58:07 DEBUG metrics: handled initialize (id 1), pid 178
log_level: off. Quiet stdout, and the diagnostics someone switched on deliberately are gone.2>/dev/null) to see what a client sees..mcp.json to run the Python file directly. It drops the banner, and the log on stdout
stays; and the wrapper was there for a reason someone will restore.<command> 2>/dev/null < requests.jsonl # stdout exactly as the client reads it
<command> 2>&1 >/dev/null < requests.jsonl # only what went to stderr
python3 -c 'import json,sys; [json.loads(l) for l in sys.stdin]' # fails on the first bad line
printf '...' >&2 # a wrapper's messages, on stderr
claude --debug # Claude Code's own view of MCP start-up
Where this lab’s hints send you for reading, beyond this journal. In the TUI, h shows a
hint’s reading and l opens a journal section; the lab’s page on the site lists the same.
mcp) — TransportsManual pages: man 3 stdout.
Documentation:
The whole subject, end to end: the topic journals The Model Context Protocol (mcp), The log lines that were never written down (logging-journald) —
the mechanism, the symptoms and their causes, exercises, and sources.
What may a stdio MCP server write to stdout?
Only JSON-RPC messages, one per line; everything else — logs, banners, progress — belongs on stderr or in a file.
Why did the banner stop the connection while the log lines did not?
The banner had no newline, so it shared a line with the answer to
initializeand that answer was lost; each log line was a line of its own, which Claude Code skips.
After moving the banner to stderr the job works. Why is the lab not finished?
The log still writes to stdout, which breaks the protocol for stricter clients and on the next missing newline; stdout must carry only protocol messages.
Why is turning logging off the wrong fix?
It removes the diagnostics someone needs, and hides the real problem instead of fixing where the output goes.
How do you see exactly what a client receives from a stdio server?
Run its command with JSON-RPC requests on stdin and stderr redirected away; every remaining line must parse as a JSON-RPC message.