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:

sequenceDiagram participant C as Client participant S as Server C->>S: server/discover S-->>C: supportedVersions, capabilities C->>S: tools/list S-->>C: name, description, inputSchema C->>S: tools/call S-->>C: content

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:

PrimitiveControlled byMy intended example
Toolthe model, from contextget_argocd_app_status(app) - the model decides it needs sync state
Resourcethe application, by URIrunbook://checkoutservice - a document the user attaches on purpose
Promptthe 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!