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 revision2025-11-25, with the official Go SDK atv1.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.
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.