Every MCP session opens with the same exchange, and no other method is legal until it finishes. The client sends initialize, the server answers with what it can do, the client sends initialized, and only then does the session move to normal operation. This post builds that handshake by hand on the jsonrpc package from the last post, runs it against a real server, and reads what actually gets negotiated.
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.
A session has three phases: initialization, operation, and shutdown. Initialization is the handshake, and it does three jobs in one round-trip. It agrees on a protocol version, it exchanges capabilities so neither side has to guess what the other supports, and it shares implementation names and versions. The sequence diagram at the top of this post is the whole lifecycle. Everything interesting happens in the first three messages.
§The Handshake by Hand
The first post drove a server with printf. This time the client is real Go code, built only on the jsonrpc package from the last post. It starts the SDK-based server as a subprocess, then speaks the handshake over its stdin and stdout:
// Start the real MCP server (the SDK server from the first post).
srv := exec.Command("./wire-demo")
stdin, _ := srv.StdinPipe()
stdout, _ := srv.StdoutPipe()
srv.Stderr = os.Stderr
srv.Start()
defer srv.Process.Kill()
conn := jsonrpc.NewConn(stdout, stdin)
// 1. initialize: offer a protocol version and declare client capabilities.
send(jsonrpc.NewRequest(1, "initialize", map[string]any{
"protocolVersion": "2025-11-25",
"capabilities": map[string]any{
"sampling": map[string]any{},
"roots": map[string]any{"listChanged": true},
},
"clientInfo": map[string]any{"name": "handmade-client", "version": "v0.1.0"},
}))
recv()
// 2. initialized: a notification, so no id and no reply. The session is open.
send(jsonrpc.NewNotification("notifications/initialized", map[string]any{}))
// 3. tools/list is now legal, which proves the handshake completed.
send(jsonrpc.NewRequest(2, "tools/list", map[string]any{}))
recv()
The send and recv helpers just marshal a message, print it with a -> or <- prefix, and write or read it through the Conn. Here is the real run, the four messages out and the two that came back:
-> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"roots":{"listChanged":true},"sampling":{}},"clientInfo":{"name":"handmade-client","version":"v0.1.0"},"protocolVersion":"2025-11-25"}}
<- {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{},"tools":{"listChanged":true}},"protocolVersion":"2025-11-25","serverInfo":{"name":"wire-demo","title":"Wire Demo Server","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":[ ... word_count with its schemas ... ]}}
A hundred lines of jsonrpc plus a subprocess, and the handshake is done. The initialized notification got no reply, as a notification must. The tools/list that followed succeeded, which is the proof the session reached operation. The same tools/list sent first would not have.
§What initialize Carries
The request has three members. protocolVersion is the revision the client wants to speak, which should be the latest it supports. capabilities is the client declaring what it can handle. clientInfo is identity: name and version are required, and 2025-11-25 adds optional title, description, icons, and websiteUrl for display.
The result mirrors it. protocolVersion is the version the server agreed to. capabilities is the server’s side of the declaration. serverInfo is the same identity shape. There is one extra optional field, instructions, a string the server can use to tell the host how it expects to be used, which a host may feed to the model as context. The server above set none, so it is absent.
§Capability Negotiation
The word “negotiation” is slightly misleading, because the two sides are not haggling over a shared list. Each side declares what it will accept being asked for, and the declarations are complementary. The client’s capabilities unlock requests that run server to client. The server’s capabilities unlock requests that run client to server. After the handshake, sending anything the other side did not advertise is a protocol violation.
tasks and experimental. Sub-flags: listChanged (prompts, resources, tools) and subscribe (resources only). In the run above the client declared sampling and roots; the server declared logging and tools.This is why the server in the first post advertised tools and logging and nothing else. It registered one tool, so the SDK turned on the tools capability and left resources and prompts off. A client reading that result knows, before it asks, that resources/read will not work here, so it does not ask. The capability object is not decoration. It is the contract for the rest of the session.
The sub-objects carry detail. tools, resources, and prompts can each set listChanged to promise a notification when their list changes, and resources alone can set subscribe to allow watching one resource. Revision 2025-11-25 made some of these richer: elicitation declares form and url modes, and tasks spells out which requests it will accept as tasks. Each of those gets its own post.
§Version Negotiation
The protocol version is dated, like 2025-11-25, and the rule is short. The client sends the latest version it supports. If the server supports that version, it must echo it back. If not, it must answer with a version it does support, which should be its own latest. If the client cannot speak the version the server returned, it should disconnect.
So the server never fails just because the client asked for something unfamiliar. It counters. Offer this server a version from years ago and watch:
-> initialize { "protocolVersion": "2024-01-01", ... }
<- result { "protocolVersion": "2025-11-25", ... }
The client asked for 2024-01-01. The server does not speak it, so it answered with 2025-11-25, the version it does speak, and handed the decision back to the client. The spec also defines a hard-failure shape for the cases where a server would rather reject than counter, a -32602 error with "message": "Unsupported protocol version" and a data object listing what it supports. Either way the client learns the truth in one message.
This matters more than it looks, because the version decides which features exist. A client and server that settle on 2025-06-18 do not have tool calls inside sampling, URL-mode elicitation, or the tasks primitive, because all three landed in 2025-11-25. The negotiated version is the line between “this method exists” and “this method does not,” so reading it off the handshake is the first thing to check when a feature mysteriously is not there.
§Why the Order Is Enforced
The handshake is not a formality the server is being polite about. Until it completes, most methods are illegal, and the server says so. Send tools/list first, with the hand-rolled client and a flag that skips initialization:
-> {"jsonrpc":"2.0","id":99,"method":"tools/list","params":{}}
<- {"jsonrpc":"2.0","id":99,"error":{"code":0,"message":"method \"tools/list\" is invalid during session initialization"}}
The server refuses, because there is nothing to list against yet: capabilities have not been exchanged and no version is agreed. The spec draws the line with two rules. The client should send nothing but a ping before the server answers initialize. The server should send nothing but a ping or a logging message before it receives initialized. A ping is the one request that is always legal, which is how either side can check the connection is alive before the session is built. Everything else waits.
§Shutdown
There is no shutdown message. The lifecycle ends through the transport, not the protocol. On stdio the client closes the server’s stdin, waits for it to exit, and escalates to SIGTERM and then SIGKILL if it does not. On HTTP, closing the connection is the signal. The hand-rolled client above shuts down by killing the subprocess in a defer, which is the blunt version of the same idea.
§The SDK Does Exactly This
None of this is special to the hand-rolled client. When the SDK connects, it sends the same handshake. The LoggingTransport capture from the REST comparison post shows the SDK client’s own initialize, declaring sampling and roots, and the server’s result declaring logging and tools, the same structure the handmade client produced byte for byte. The SDK adds schema validation, capability tracking, and reconnection on top, but the first exchange on the wire is identical, because the protocol gives it no choice. Having written it once by hand, every handshake in a log now reads like a sentence.
txn2/mcp-data-platform leans on capability negotiation in a pointed way. It declares its tool capability at the handshake, and its default-deny personas then map each user’s verified identity to an allowed set of tools, so two users complete the same handshake and see different tool lists afterward. The capabilities exchange is where that filtering begins.
§What’s Next
The handshake assumed something this post skipped past: that bytes were already moving between two processes. The next post, Transport I, stdio: Two Pipes and a Subprocess, builds the layer underneath, the one that actually carried every frame above. It writes a stdio transport by hand on the jsonrpc package, covers the framing rules and the stderr gotcha that swallows server logs, and then shows the SDK’s StdioTransport and CommandTransport doing the same job.
The production data platform behind this series is txn2/mcp-data-platform, available hosted as Plexara.