There is no handshake in the current MCP specification. Three requests, each carrying its own protocol version, and only the third one reaches your tool:
Most explanations of the Model Context Protocol stop at “it is like USB-C for AI”. That tells you the protocol exists and nothing about what it does, which is a problem if you are about to build a server with it. I am about to build one - infra-brain, which will answer infrastructure questions from ArgoCD, Kubernetes and Grafana instead of five browser tabs - and I gave myself a week of reading before writing any code.
This post is what that week was worth. No implementation yet: I read the specification and the
docs, then the Go SDK’s examples,
which are laid out as server, client, http and auth. Nearly every MCP sample you meet is
Python, so the Go being there at all is worth knowing about.
One JSON object per line, and that is the entire transport Link to heading
MCP is JSON-RPC 2.0 over a transport. For local
development that transport is stdio: the client launches your server as a subprocess and talks
to it over the standard streams. The
binding is four
sentences long. Messages are newline delimited and must not contain embedded newlines. The server
must not write anything to stdout that is not a valid MCP message. It may write whatever it likes
to stderr, and the client “SHOULD NOT assume stderr output indicates error conditions”.
No length prefix, no envelope, no handshake bytes. One consequence I had not expected: since the
framing is newline-delimited JSON on standard streams, an MCP server is a program you can drive
with printf and read with jq. That is the sort of thing I expect to want in month three, when
something is broken and the only question is whether the fault is mine or the client’s.
The rule with teeth is the stdout one. A single stray fmt.Println, or a library that logs to
stdout by default, corrupts the stream and the client disconnects. There is no compile error for
it, which is why it is already written down as a constraint rather than left to memory.
The handshake the specification no longer has Link to heading
This is the part that did not click, and the reason I am glad I read the specification rather than a summary of it.
I started out expecting initialize - a client and server agreeing on a version, exchanging
capabilities, and holding a session open. That is how MCP worked, and it is what almost everything
written about it still describes. The current revision, 2026-07-28, deleted it. No handshake,
no session. Every request carries its own protocol version and client capabilities in a
_meta.io.modelcontextprotocol/* field, and the server accepts or rejects each one independently.
A server/discover request looks like this:
{
"jsonrpc": "2.0",
"id": "discover-1",
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
The spec names the two worlds: modern for 2026-07-28 and later, legacy for 2025-11-25 and earlier, with a compatibility matrix for every combination of the two.
The design consequence is the thing I took away: write nothing that depends on a session. No per-connection cache, no “the client told me X at startup so I will remember it”. The specification’s guidance for stateful tools is to return an opaque handle and accept it as an argument to the next call, so state travels on the wire or it does not exist. That costs nothing to commit to before there is code, and would be expensive to unpick afterwards.
Three primitives, and the difference is who decides Link to heading
The other thing worth reading the spec for. Servers expose tools, resources and prompts, and they differ by who controls when they are used, not by what they contain:
| Primitive | Controlled by | My intended example |
|---|---|---|
| Tool | the model, from context | get_argocd_app_status(app) - the model decides it needs sync state |
| Resource | the application, by URI | runbook://checkoutservice - a document the user attaches on purpose |
| Prompt | the user, explicitly | /incident-triage <service> - a workflow a person starts |
The test I settled on: if the answer changes every time you ask, it is a tool; if it has a stable address you would want to link to, it is a resource; if a person starts it, it is a prompt. Most servers ship everything as a tool and so will my first version - but by choice, rather than through not knowing there were three.
This matters because of the middle request in that diagram. Every tool’s name, description and full input schema goes into the model’s context on every turn, not once at startup. The tool surface is a context budget. Twenty mediocre tools is not a richer server, it is a worse one.
What the SDK hides, and what it cannot Link to heading
I will be using the official modelcontextprotocol/go-sdk,
v1.7.0. Reading the wire first makes what the examples are doing legible rather than magic:
AddTool generates the tool’s JSON Schema from a Go struct’s tags and validates arguments against
it, so the schema and the type cannot drift apart, and the SDK speaks five protocol revisions, which
turns the modern-versus-legacy problem above into configuration rather than work.
What it cannot do is the stdout discipline, the timeouts, or the choice of which of the three
primitives a thing should be. Those are mine, and they are the ones I expect to get wrong first.
What I have not built Link to heading
Nothing, yet. There is no Go in the repository - only two architecture decision records and a diagram, which is what this week was for. So everything above is the specification and the SDK’s examples read carefully, and none of it is a report from running code. I can tell you what the protocol says; I cannot yet tell you what it does when it is wrong, which is the half that usually matters.
That is the next post. First a server skeleton over stdio, then one real tool,
get_argocd_app_status, against a live ArgoCD in a lab cluster. Two things I will come back and
answer: whether the legacy compatibility behaves as the SDK’s README table claims, because the whole
plan rests on that assumption, and whether the no-session rule above survives contact with something
that genuinely wants to cache.
If you have built an MCP server and something above is wrong, please tell me - I would rather find out now than in month three. I will be glad to hear from you if you have any question(s) or feedback, have fun!