Index Ai Academy
Phase 9 · Model Context Protocol · Protocol foundations

Transports I: stdio

Newline-delimited JSON over a subprocess's standard streams.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Most MCP servers people install are local: a file system server, a git server, a database helper. They run on your own machine, started by the app you're using, and they talk over the oldest interface in computing, standard input and output. It's simple, needs no network or ports, and works in every language. It also has one famous trap that breaks more first servers than any other bug.

What you'll be able to do

  • Explain how a host runs a local MCP server as a subprocess and talks over its standard streams
  • Frame messages as newline-delimited JSON, and keep stdout clean
  • Handle cancellation and shutdown on stdio
  • Write the read-dispatch-write loop at the heart of every stdio server

What it covers

  1. A server as a subprocess
  2. Framing: one message per line
  3. The famous trap: print()
  4. A stdio session, simulated
  5. Cancellation and shutdown
  6. The bugs that only stdio has

A server as a subprocess

Picture two people in adjoining rooms, passing notes through a slot in the wall. That's the stdio transport. A transport is how messages physically travel. With this one, the host (Claude Code, Claude Desktop, an editor) starts the server as a child process, a program it launches and controls. Every process has three standard streams:

  • stdin: the host writes requests here, and the server reads them. It's the slot coming in.
  • stdout: the server writes responses and notifications here, and the host reads them. It's the slot going out.
  • stderr: the server may write log text here. Hosts usually save or show it, and never parse it as protocol. Think of it as a side notepad.

Hosts list local servers in a config file. The shape varies by app, but it's usually a command, its arguments, and environment variables:

{
  "mcpServers": {
    "shop": {
      "command": "uv",
      "args": ["run", "shop_server.py"],
      "env": {"SHOP_API_KEY": "sk_live_..."}
    }
  }
}

Notice how credentials arrive: through environment variables. The spec says stdio servers should take credentials from the environment, not run the OAuth flow used by remote servers.

Try it now

Three streams, one rule: nothing but protocol on stdout.

Framing: one message per line

A stream of bytes has no built-in message boundaries. It's a long strip of tape with no gaps. So both sides need a framing rule, which says where one message ends and the next begins. MCP's rule for stdio is the simplest possible: each message is one line of JSON, ended by a newline, and a message must never contain a raw newline inside it.

That sounds fragile, since text often contains newlines. But JSON escapes them. A newline inside a string is written as the two characters \n, so json.dumps never produces a raw line break unless you ask it to indent:

import json

msg = {"jsonrpc": "2.0", "id": 1, "result": {"content": [{"type": "text", "text": "line one\nline two"}]}}
line = json.dumps(msg)
print(line)
print("raw newlines inside:", "\n" in line)
print("with indent=2:", "\n" in json.dumps(msg, indent=2), "<- never do this on stdio")
It runs right here in your browser, with nothing to install.
Quick check
What does this print?
stream = '{"id":1}\n{"id":2}\n'
print(len(stream.strip().split("\n")))
  1. 6
  2. 1
  3. 2
  4. 3
Show the answer

2

strip removes the last line break, and split cuts what's left into 2 lines. One JSON message per line, so the line break is the frame between messages.

Quick check
Why is json.dumps(msg, indent=2) a bug for a stdio MCP server?
  1. MCP requires tabs, not spaces
  2. Indenting adds raw newlines, so one message spans several lines and the host can't parse it
  3. It isn't a bug; hosts accept any JSON
  4. Indented JSON is too slow
Show the answer

Indenting adds raw newlines, so one message spans several lines and the host can't parse it

The host reads one line as one message. A pretty-printed message arrives as several broken fragments.

The famous trap: print()

On stdio, stdout is the protocol channel. Think of an old telegraph line, where everything on the wire gets read as the message. The server must never write anything to stdout that isn't a valid MCP message. Now picture a tool function with a friendly debug line:

def get_campaign(campaign_id):
    print("looking up", campaign_id)        # writes to stdout!
    return fetch(campaign_id)

That stray text lands in the middle of the host's message stream. The host tries to parse looking up cmp_103 as JSON, fails, and typically drops the connection. The server "works" when you test the function directly, and breaks the moment it runs inside a host.

The fixes:

  • Log to stderr: print(..., file=sys.stderr), or use logging, which writes to stderr by default.
  • Defend in depth: while running handlers, redirect anything they print (including what libraries print) to stderr. The exercise does exactly this.

In plain words: stdout is for protocol messages and nothing else. Everything meant for humans goes to stderr.

The loop

A stdio server is a loop, like a clerk working through an in-tray:

  1. Read one line from stdin. End of input (EOF) means the host closed the pipe: exit cleanly.
  2. Skip blank lines.
  3. Dispatch the message (the dispatcher from two lessons ago).
  4. If there's a reply, write it as one line to stdout, then flush, so it leaves the buffer now. A buffer holds output until there's enough to send, like a mail carrier waiting for a full bag. Flushing sends what's there.

Real servers handle several requests at once, so replies can go out in a different order from the requests. The ids match them up.

Quick check
Where should a stdio MCP server write its logs?
  1. To stderr, because stdout carries only protocol messages
  2. Into the tool results
  3. To stdout, so the host sees them
  4. Nowhere; stdio servers can't log
Show the answer

To stderr, because stdout carries only protocol messages

Anything extra on stdout corrupts the message stream. stderr is free for logs.

Worked example

A stdio session, simulated

The academy can't start subprocesses, so io.StringIO stands in for the pipes. The loop is the same as a real server's.

import io, json, sys

def dispatch(line):
    msg = json.loads(line)
    if "id" not in msg:
        return None
    if msg["method"] == "tools/list":
        return {"jsonrpc": "2.0", "id": msg["id"], "result": {"resultType": "complete", "tools": []}}
    return {"jsonrpc": "2.0", "id": msg["id"], "error": {"code": -32601, "message": "Method not found"}}

stdin = io.StringIO(
    '{"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}\n'
    '\n'
    '{"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}}\n'
    '{"jsonrpc": "2.0", "id": 2, "method": "prompts/list", "params": {}}\n'
)
stdout = io.StringIO()
for line in stdin:                    # reading stops at end of input (EOF)
    if not line.strip():
        continue
    reply = dispatch(line)
    if reply is not None:
        stdout.write(json.dumps(reply) + "\n")
        stdout.flush()

print(stdout.getvalue())
In the lesson you can change this and run it.

Four lines in: a request, a blank line, a notification and another request. Two lines out, one per request. On your own machine you'd loop over sys.stdin and write to sys.stdout, and the host would see exactly these lines.

Cancellation and shutdown

  • Cancellation. stdio has one shared channel, so a client cancels a request by sending a notification: notifications/cancelled with the requestId. The server should stop that work and send nothing more for that id. A reply that was already on its way may still arrive, and clients ignore it.
  • Shutdown. The client closes the server's stdin and waits. A good server exits promptly at end of input. If it doesn't, the client escalates, the way a bar does at closing time: a polite SIGTERM, then a forced SIGKILL.
  • Crashes. If the server dies, the host restarts it. Modern MCP is stateless, so nothing is lost except the requests that were in flight, and those can be retried.

The same framing (one JSON message per line) works over any reliable two-way byte stream, such as a local socket. Only the subprocess parts, like startup and stderr, are specific to stdio.

Quick check
How does a host ask a stdio server to shut down gracefully?
  1. It closes the server's stdin; the server should exit when it reads end of input
  2. It deletes the server's config
  3. It writes EXIT to stdout
  4. It sends a shutdown request and waits for a reply
Show the answer

It closes the server's stdin; the server should exit when it reads end of input

End of input is the portable signal. SIGTERM and then SIGKILL are only for servers that don't exit on their own.

The bugs that only stdio has

print() breaks everything. Stdout belongs to the protocol. One stray print("got here") puts text in the stream where the client expects JSON, and the connection fails with a confusing parse error. It's the first thing to check when a local server dies right after starting. Send your debug output to stderr instead:

import sys

print("debug: got here", file=sys.stderr)

Pretty-printed JSON breaks framing. Each message must fit on one line, since the line break is what separates messages. json.dumps(msg, indent=2) spreads one message over many lines, and the client tries to parse each line on its own. Use plain json.dumps(msg), which contains no line breaks.

Flush after every message. Python often holds output in a buffer when it's writing to a pipe, not a screen. So your reply can sit there while the client waits, like a letter in your bag you forgot to mail. Both sides hang with no error. Call sys.stdout.flush() after each write, or print with flush=True.

Local servers don't need OAuth. A stdio server runs on the user's own machine, started by their host. It reads credentials from environment variables, set in the host's config. Adding a browser login flow to it only creates something more to go wrong.

Practice what you just read

  • 4 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

On stdio, where may a server write debug logs?

  1. stderr
  2. stdout
  3. Either one
  4. Nowhere; logging isn't allowed

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson