MCP 2026-07-28 one lesson per page
23 lessons
Common source of bugsArchitecture & Components · 14%Track 1 · 5 / 9

stdio or HTTP: the pipes, and keeping them clean

Two transports. stdio is a subprocess talking newline-delimited JSON; Streamable HTTP is one POST endpoint. Each has a handful of rules that cause most real-world breakage.

Listen to this lessonAudio overview in Gemini Notebook · about 15–25 min · opens in a new tab

The debug print that broke production

A developer adds console.log("server starting") to their stdio MCP server to debug a problem. Everything breaks: the client tries to parse server starting as JSON-RPC and drops the connection.

stdout is the protocol. The fix is one character away: log to stderr, which the spec explicitly reserves for logging.

Same week, different team: a local HTTP MCP server bound to 0.0.0.0, with no Origin check. A malicious web page uses DNS rebinding to make the user's own browser call http://localhost:3000/mcp and run tools. Two rules stop it: validate Origin (answer an invalid one with 403) and bind to 127.0.0.1.

stdio

  • The client launches the server as a subprocess. Messages are newline-delimited and MUST NOT contain embedded newlines.
  • The server MUST NOT write anything to stdout that isn't a valid MCP message. It MAY log to stderr, and the client SHOULD NOT treat stderr output as an error.
  • Shutdown: the client closes stdin, waits, then terminates if needed (SIGTERM, then SIGKILL). Servers SHOULD exit promptly when stdin closes. If the server crashes, the client SHOULD restart it; in-flight requests are simply lost.
  • stdio servers don't use the OAuth flow: they SHOULD take credentials from the environment.

Streamable HTTP

  • One endpoint, POST only. Servers MUST validate Origin and answer an invalid one with 403. Local servers SHOULD bind to 127.0.0.1, not 0.0.0.0. All connections SHOULD be authenticated.

Progress: the client puts a progressToken (string or integer, unique among active requests) in _meta. progress MUST increase with each notification, even without a total, and notifications MUST stop after completion.

Cancellation: on HTTP, closing the response stream is the signal. On stdio, the client MUST send notifications/cancelled with the requestId. Either way the server stops and sends nothing more for that request. Both sides SHOULD enforce a maximum timeout.

Most "MCP is flaky" bug reports are transport hygiene: a stray print on stdout, a server that ignores a closed stdin, a local endpoint reachable from any website. These rules are short, and following them removes a whole class of outages and attacks.

stdio: progress, then a cancel
ClientServer (subprocess)tools/call build_report (id 7, progressToken p7)notifications/progress 1/10notifications/progress 2/10notifications/cancelled {requestId: 7}Server stops; sends nothing more for id 7
Solid arrows are requests, dashed are responses or notifications. Red ✕ is the unsafe or failing step; green is the step that keeps you safe.

What a stdio server may write to stdout

Unsafestdout
server starting on stdio...{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","tools":[]}}
Safestdout (logs went to stderr)
{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","tools":[]}}

One message per line, nothing else. The first line on the left breaks every client.

Progress and cancellation messages

Exampleprogress
{  "jsonrpc": "2.0",  "method": "notifications/progress",  "params": {    "progressToken": "abc123",    "progress": 50,    "total": 100,    "message": "Reticulating splines..."  }}
Examplecancel (stdio)
{  "jsonrpc": "2.0",  "method": "notifications/cancelled",  "params": {    "requestId": "123",    "reason": "User requested cancellation"  }}

Both verbatim from the spec.

  • Libraries that print. A dependency that writes a banner to stdout will break a stdio server. Redirect it to stderr.
  • Orphan processes. Servers that ignore a closed stdin keep running after the host quits.
  • DNS rebinding against local HTTP servers without Origin checks or localhost binding.
  • Progress that goes backwards, or keeps arriving after the result: both break the MUSTs.
First time here? Set up the test helper (once per terminal)
ShellSetup
# 1. In a SECOND terminal, start the reference server (Node 18+, no dependencies)curl -sO https://www.diegozuluaga.dev/mcpa/reference-server.mjsnode reference-server.mjs                 # http://localhost:3000/mcp, logs appear here # 2. In THIS terminal, define the helper every test uses (bash or zsh)export MCP=http://localhost:3000/mcpMETA='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}'mcp() {  # usage: mcp <method> '<json body>' [extra curl args...]  curl -sS -N "$MCP" \    -H 'Content-Type: application/json' \    -H 'Accept: application/json, text/event-stream' \    -H 'MCP-Protocol-Version: 2026-07-28' \    -H "Authorization: Bearer ${MCP_USER:-alice}" \    -H "Mcp-Method: $1" "${@:3}" -d "$2" \    -w '\nHTTP %{http_code}\n'}# Demo auth: the reference server treats the bearer token as the user's name.# Prefix a command with MCP_USER=bob to act as someone else.
ShellTests
# 1. stdio: stdout carries only JSON (logs go to stderr, hidden here with 2>/dev/null){ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'; sleep 1; } | node reference-server.mjs --stdio 2>/dev/null | head -c 120; echo # 2. stdio: start a slow call with a progress token, cancel it after 2.5 s{ printf '%s\n' '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"build_report","arguments":{"month":"2026-09"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{},"progressToken":"p7"}}}'; sleep 2.5; printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":7,"reason":"User requested cancellation"}}'; sleep 2; } | node reference-server.mjs --stdio# expect: two progress notifications, the stderr line "build_report cancelled", and NO result for id 7 # 3. HTTP: a request from a foreign Origin is refusedcurl -sS -o /dev/null -w 'evil origin -> %{http_code}\n' "$MCP" -H 'Origin: https://evil.example' -H 'Content-Type: application/json' -d '{}'# expect: 403

Answer all 4 correctly and this lesson is marked as learned.

Q1Where should a stdio MCP server write its debug logs?

Q2How does a client cancel an in-flight request on stdio?

Q3A local HTTP server receives a request with Origin: https://random-site.example. What should it do?

Q4Spot the bug: a server sends progress 30, then 20, then 50 for the same token.

Feedback or a correction? Email diego [at] diegozuluaga [dot] dev or open an issue on GitHub.

Content CC BY 4.0 · Code MIT

Tip: ← and → move between lessons. Hover any heading and press # to copy a link to it.