Also at Deasil Works · txn2 · Plexara
Profiles GitHub · X · LinkedIn
Theme Light · Auto · Dark
Professional notes by Craig Johnston
long-form, short-form, working drafts · since 2008
VOL. XIX · MMXXVI
106 NOTES IN PRINT
FOLIO LXXXVIII 2026-05-09 · 7 MIN · SHORT-FORM

Transport I, stdio: Two Pipes and a Subprocess

The simplest MCP transport, built by hand on both sides with no SDK, plus the stderr rule that silently breaks servers

Diagram · folio lxxxviii
flowchart LR
  C["client process"] -->|"stdin: requests in"| S["server subprocess"]
  S -->|"stdout: responses out, protocol only"| C
  S -->|"stderr: logs, never protocol"| L["terminal / log file"]

The stdio transport is the simplest one MCP defines. The server is a subprocess, the client owns its standard input and output, and JSON-RPC messages move one per line between them. Nothing more. This post builds that transport by hand on both sides, with no SDK anywhere, then shows the SDK doing the same job and the one framing rule that breaks more servers than any other.

This is part of MCP on the Wire, a series that takes the Model Context Protocol apart message by message, in Go. It comes out of building and running MCP servers in production, including the open-source txn2/mcp-data-platform, an Apache-2.0 platform in Go that connects AI assistants to Trino, DataHub, and S3 through one MCP endpoint, enriching every result with semantic context (ownership, lineage, PII, data quality) behind OAuth 2.1 auth, personas, and an audit trail. Everything here is read straight off the wire against spec revision 2025-11-25, with the official Go SDK at v1.6.1.

JSON-RPC says nothing about how bytes move. The grammar post built the messages; the transport is what carries them between two processes. MCP defines two: stdio and Streamable HTTP. stdio is the default, and it is what every editor and desktop host uses to run a local server, because it needs no ports, no sockets, and no network. It is three streams and a process, drawn at the top of this post.

§What the Transport Is Responsible For

The contract is short. The client starts the server as a child process. It writes JSON-RPC requests and notifications to the server’s standard input, one JSON object per line, and reads responses and notifications back from the server’s standard output, the same way. Messages are UTF-8 and contain no embedded newlines, because the newline is the frame boundary. Standard error is a third stream, and it is not part of the protocol at all.

stdinclient → serverrequests and notifications, one JSON object per line
stdoutserver → clientresponses and notifications. protocol only nothing else may ever be written here
stderrserver → youlogs, diagnostics, anything human. never read as protocol

That third row is the whole reason this post exists, and the next section earns it. First the easy part.

§The Server Side, by Hand

A stdio server is a loop. Read a message from standard input, dispatch by method, write the reply to standard output. The jsonrpc package from the grammar post already does the reading and writing, so the server is a switch statement over method names. Here is the core, with no SDK:

// Logs go to stderr. stdout is reserved for protocol messages, nothing else.
logger := log.New(os.Stderr, "[stdio-server] ", 0)
conn := jsonrpc.NewConn(os.Stdin, os.Stdout)
initialized := false

for {
	msg, err := conn.Read()
	if err == io.EOF {
		logger.Println("stdin closed, exiting")
		return
	}
	logger.Printf("recv %-13s method=%q", msg.Kind(), msg.Method)

	switch msg.Method {
	case "initialize":
		conn.Write(msg.Reply(map[string]any{
			"protocolVersion": "2025-11-25",
			"capabilities":    map[string]any{"tools": map[string]any{}},
			"serverInfo":      map[string]any{"name": "handmade-stdio", "version": "v0.1.0"},
		}))
	case "notifications/initialized":
		initialized = true // a notification: no reply
	case "tools/list":
		conn.Write(msg.Reply(map[string]any{"tools": []any{wordCountTool}}))
	case "tools/call":
		// unmarshal params, run word_count, reply with content + structuredContent
	}
}

Drive it with printf, the way the first post drove the SDK server, and it answers:

{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":{}},"protocolVersion":"2025-11-25","serverInfo":{"name":"handmade-stdio","version":"v0.1.0"}}}
{"jsonrpc":"2.0","id":2,"result":{"content":[{"text":"{\"chars\":9,\"words\":2}","type":"text"}],"structuredContent":{"chars":9,"words":2}}}

A full MCP server over stdio, in one read loop, importing nothing but the standard library and the hand-written grammar. The logger writes to standard error, so its output never appears in the stream above. Run the same session with standard error visible and standard output hidden, and the two are cleanly separated:

[stdio-server] recv request       method="initialize"
[stdio-server] stdin closed, exiting

§stdout Is Sacred

Here is the rule that breaks servers: standard output must contain protocol messages and nothing else. Not a startup banner, not a stray fmt.Println, not a library that logs to standard output by default, not a panic stack trace. The client parses every line on standard output as JSON-RPC, so the first non-message line is a parse error.

The server has a -noisy flag that writes one innocent line to standard output before the loop starts:

if *noisy {
	os.Stdout.WriteString("starting up...\n")
}

That single line is enough. Pipe the server’s output through a JSON parser and it fails on the first read:

$ printf '%s\n' '{...initialize...}' | ./stdioserver -noisy | jq .
jq: parse error: Invalid numeric literal at line 1, column 9

The server is otherwise perfect. Its handshake is correct, its tool works. It is unusable, because something put four words on standard output and the client’s parser hit them before it ever saw a valid message. This is the single most common way a working server fails in the wild: a dependency, or a debug print left in, writes to standard output. The fix is the discipline the figure above states, all human output goes to standard error. The spec is explicit that standard error is free for any logging and must not be treated as part of the protocol, which is exactly why it is the safe place to write.

§The Client Side, by Hand

The client owns the other ends of those pipes. It starts the server, takes its standard input and output as an io.Writer and io.Reader, and wraps them in the same Conn:

srv := exec.Command(*server)
stdin, _ := srv.StdinPipe()   // the client writes requests here
stdout, _ := srv.StdoutPipe() // the client reads responses here
srv.Stderr = os.Stderr        // let the server's logs flow to the terminal
srv.Start()
defer srv.Process.Kill()

conn := jsonrpc.NewConn(stdout, stdin)

That is the entire client transport. Point this hand-rolled client at the hand-rolled server and a full session runs with no SDK on either side, over real operating-system pipes:

-> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{ ...client caps... }}
<- {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":{}},"protocolVersion":"2025-11-25","serverInfo":{"name":"handmade-stdio","version":"v0.1.0"}}}
-> {"jsonrpc":"2.0","method":"notifications/initialized","params":{}}
-> {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
<- {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"word_count", ...}]}}

Client process, server subprocess, three streams, newline frames. Everything in the first four posts of this series has been running on exactly this, whether the bytes came from the SDK or from printf.

§Shutdown Is the Transport’s Job

There is no shutdown message in the protocol, as the handshake post noted. On stdio, the client ends the session through the pipes. It closes the server’s standard input, which the server sees as io.EOF on its read loop, the cue to exit. If the server does not exit on its own, the client waits, then sends SIGTERM, then SIGKILL. The hand-rolled client above takes the blunt shortcut of killing the process in a defer. A correct client follows the full sequence.

§The SDK Version

The SDK packages all of this behind two types. On the server, StdioTransport{} is the read loop, and server.Run(ctx, &mcp.StdioTransport{}) is the whole program, which the first post already used. On the client, CommandTransport is the subprocess side:

transport := &mcp.CommandTransport{Command: exec.Command("./server")}
session, err := client.Connect(ctx, transport, nil)

CommandTransport does the shutdown sequence properly. It has a TerminateDuration field that, in the SDK’s own words, “controls how long Close waits after closing stdin for the process to exit before sending SIGTERM,” defaulting to five seconds. That is the close-stdin, wait, SIGTERM sequence from the spec, implemented and configurable, so you do not have to. The SDK is the hand-rolled transport above with the edge cases handled.

§Which Go SDK

There are two Go SDKs worth knowing, and this series uses the official one, so the choice is worth a paragraph. The official github.com/modelcontextprotocol/go-sdk is developed in collaboration with Google, reached a stable v1.0 with a compatibility guarantee, and aims at full, current spec compliance with typed tool handlers and a deliberately small API. The older community SDK, github.com/mark3labs/mcp-go, came first, is widely deployed, and shaped the official design. It leans toward richer server ergonomics: per-session tools, request hooks, handler middleware. Both now speak stdio and Streamable HTTP. The rule of thumb: reach for the official SDK when you want spec compliance and long-term stability, which is what a teaching series needs, and consider mark3labs when its extra server features fit the job. The wire underneath is identical either way, which is the point of reading it.

txn2/mcp-data-platform runs over exactly this transport for local use, a subprocess speaking newline-delimited JSON-RPC, and switches to Streamable HTTP when it is deployed as a shared service. The server logic does not change between them; the transport is a layer around it.

§What’s Next

stdio runs a server on the same machine as the client. To reach a server across a network, behind auth, serving many clients at once, MCP uses its other transport. The next post, Transport II, Streamable HTTP: One Endpoint, Two Directions, builds it: a single HTTP endpoint that handles both directions, the Mcp-Session-Id header that carries state across requests, the server-sent-events stream the server pushes through, and resuming a dropped stream with Last-Event-ID.


The production data platform behind this series is txn2/mcp-data-platform, available hosted as Plexara.

← back to all notes