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 LXXXIII 2026-05-01 · 12 MIN · LONG-FORM

Why Read the Wire When the SDK Hides It

Drive a real Go MCP server with four lines of JSON and watch the protocol the SDK keeps out of sight

Diagram · folio lxxxiii
sequenceDiagram
  autonumber
  participant C as Client (host)
  participant S as Server (your Go binary)
  C->>S: initialize { protocolVersion, capabilities, clientInfo }
  S->>C: result { protocolVersion, capabilities, serverInfo }
  C--)S: notifications/initialized
  C->>S: tools/list
  S->>C: result { tools: [ word_count + schemas ] }
  C->>S: tools/call { name, arguments }
  S->>C: result { content, structuredContent }

You write a Go function, tag its arguments, and hand it to mcp.AddTool. A model can then call it. That is the promise of the Model Context Protocol, and the SDK delivers it in about a dozen lines. You never see a byte of the protocol, until you have to.

This is the opening 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 the current spec revision, 2025-11-25, with the official Go SDK at v1.6.1.

The day arrives when a tool result renders perfectly in one client and as garbage in another. Or a session drops mid-call and you cannot tell whether the work ran. Or an auth handshake 401s in a loop with no useful error. Or a server you do not control quietly slips text into your model’s context. None of those problems live in your AddTool call. They live one layer down, on the wire, in the JSON the SDK was kind enough to hide from you. The SDK is a convenience, and conveniences leak. When yours does, the person who can read the wire ships the fix, and the person who cannot files an issue and waits.

This series is about reading the wire. The good news is that there is not much wire to read. MCP is small enough to understand completely, and once you can see the messages, the SDK stops being magic and starts being a typist that saves you keystrokes. I build everything in Go here, and I show the raw JSON next to the typed code that produces it, every time.

Agent / Hostthe model + your app
decides a tool should be called
MCP Clientsession.CallTool(ctx, &params)
the SDK hides everything below this line
the wire: what this series reads
JSON-RPC 2.0{"method":"tools/call","params":{…}}
a request object with an id, a method, and params
Transportstdio · Streamable HTTP
moves the bytes between two processes
the wire: what this series reads
MCP Servermcp.AddTool(server, tool, handler)
the SDK hides everything above this line
Your Toolfunc wordCount(ctx, req, in) (…)
runs, returns a result

Two SDKs sit on the path of every tool call, one on the client and one on the server, and between them is a thin band of JSON-RPC over a transport. That band is the protocol. Everything above and below it is library code that you could, in principle, write yourself. This first post does the one thing that makes the rest of the series click: it sends that JSON by hand and reads the answer.

§What the SDK Does For You

Here is a complete MCP server in Go. It exposes one tool, word_count, that counts the words and characters in a string. Create a directory, initialize a module, and add the official SDK:

mkdir wire-demo && cd wire-demo
go mod init example.com/wiredemo
go get github.com/modelcontextprotocol/go-sdk/mcp

Then main.go:

package main

import (
	"context"
	"strings"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

// CountInput is the typed argument the model must supply. The `jsonschema`
// struct tags become the property descriptions the agent reads.
type CountInput struct {
	Text string `json:"text" jsonschema:"the text to measure"`
}

// CountOutput is the typed result. The SDK turns this into an output schema
// and structured content on the wire.
type CountOutput struct {
	Words int `json:"words" jsonschema:"number of whitespace-separated words"`
	Chars int `json:"chars" jsonschema:"number of unicode characters"`
}

func wordCount(ctx context.Context, req *mcp.CallToolRequest, in CountInput) (*mcp.CallToolResult, CountOutput, error) {
	out := CountOutput{
		Words: len(strings.Fields(in.Text)),
		Chars: len([]rune(in.Text)),
	}
	return nil, out, nil
}

func main() {
	server := mcp.NewServer(&mcp.Implementation{
		Name:    "wire-demo",
		Title:   "Wire Demo Server",
		Version: "v0.1.0",
	}, nil)

	mcp.AddTool(server, &mcp.Tool{
		Name:        "word_count",
		Description: "Count the words and characters in a piece of text.",
	}, wordCount)

	// Run blocks, speaking newline-delimited JSON-RPC over stdin/stdout.
	if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
		panic(err)
	}
}
go mod tidy
go build -o wire-demo .

That is the entire server. Notice what you did not do. You did not write a JSON schema, even though the model needs one to know how to call the tool. You did not write code to advertise the tool, parse a request, validate the arguments, or format a response. You did not pick a wire format or open a socket. AddTool and Run did all of it. That is the convenience, and it is real. It is also exactly the part you cannot see, which is the part this post makes visible.

§The Whole Protocol Is JSON-RPC Over a Pipe

Before sending anything, two facts about MCP that the official specification states plainly and that explain everything you are about to see.

First, the wire format is JSON-RPC 2.0. That is a tiny, twenty-year-old standard with exactly three message shapes. A request is a JSON object with a jsonrpc version, an id, a method name, and a params object. A response carries the same id back, with either a result or an error. A notification is a request with no id, which means it expects no reply. That is the whole grammar. Everything MCP does, every tool call, every resource read, every prompt, is one of those three shapes with an MCP method name like tools/call in it.

Second, MCP is a stateful, two-way session, not a series of independent REST calls. It was modeled on the Language Server Protocol, the thing that lets one editor talk to any language’s tooling. The shape is the same: a host (your app, the thing with the model) runs a client, and the client holds a connection to a server (your tool provider). The connection opens with a handshake, stays open, and carries messages in both directions until someone closes it. Hold on to the word stateful. I prove it on the wire at the end.

So if MCP is just JSON-RPC, and the server above is just reading stdin and writing stdout, then nothing stops you from being the client by hand. You can type the JSON yourself and pipe it in.

§Read the Wire Yourself

The server speaks newline-delimited JSON-RPC over stdin and stdout, one JSON object per line. Feed it four lines, in order, and read what comes back. The four lines are the smallest useful conversation you can have with an MCP server:

  1. initialize: open the session and negotiate.
  2. notifications/initialized: confirm the handshake (a notification, so no reply).
  3. tools/list: ask what tools exist.
  4. tools/call: actually call one.

printf writes the four lines, and the trailing sleep holds the pipe open for a moment so the server has time to answer before stdin closes. Piping the output through jq just makes it readable:

{ printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl-by-hand","version":"0"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
  '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"word_count","arguments":{"text":"read the wire"}}}'
  sleep 1.5
} | ./wire-demo 2>/dev/null

You just acted as the entire client. No SDK on your side, no library, just four lines of JSON and a pipe. Here is what the server sent back, line for line. This is real output from the binary above, not a sketch.

§Response 1: The Handshake

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "logging": {},
      "tools": { "listChanged": true }
    },
    "serverInfo": {
      "name": "wire-demo",
      "title": "Wire Demo Server",
      "version": "v0.1.0"
    }
  }
}

This is the reply to initialize, matched to the request by "id": 1. Three fields, and each one matters.

protocolVersion is the revision the server agreed to speak. I offered 2025-11-25 and it accepted. capabilities is the server announcing what it can do, so the client never has to guess: it supports tools, and the listChanged flag means it can notify the client later if its tool list changes. It also advertises logging, which the SDK turned on by default. I never registered a resource or a prompt, so those capabilities are simply absent, and a client that sees this knows not to ask for them. serverInfo is the name and version I set in NewServer, carried straight through to the wire.

That is capability negotiation, and it is the reason MCP clients and servers from different vendors interoperate. Nobody assumes anything. Both sides declare what they support in the first exchange, and they speak only the overlap.

§Response 2: The Tool Catalog

The notifications/initialized line produced no response, exactly as the spec says a notification should. The next reply is to tools/list, "id": 2:

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "word_count",
        "description": "Count the words and characters in a piece of text.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "text": { "type": "string", "description": "the text to measure" }
          },
          "required": ["text"],
          "additionalProperties": false
        },
        "outputSchema": {
          "type": "object",
          "properties": {
            "words": { "type": "integer", "description": "number of whitespace-separated words" },
            "chars": { "type": "integer", "description": "number of unicode characters" }
          },
          "required": ["words", "chars"],
          "additionalProperties": false
        }
      }
    ]
  }
}

Stop and look at this, because this is what the agent actually sees. When a model decides whether to call your tool and how to fill in its arguments, this JSON object is what it reads. Not your Go code. This.

And every part of it came from the Go you wrote. The name and description are the fields you passed to AddTool. The inputSchema is a JSON Schema document the SDK generated from your CountInput struct: the text field became a required string, and its jsonschema tag became the human description the model reads. The SDK even set additionalProperties: false, which tells the model not to invent fields you did not ask for. The outputSchema came the same way from CountOutput. You wrote two small Go structs and the SDK produced the precise, machine-readable contract the model needs. That is the work AddTool did for you, and now you can see it.

§Response 3: The Call

Finally, the reply to tools/call, "id": 3, where the tool actually ran:

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [
      { "type": "text", "text": "{\"chars\":13,\"words\":3}" }
    ],
    "structuredContent": { "chars": 13, "words": 3 }
  }
}

The string "read the wire" is thirteen characters and three words, and that is what came back. The interesting part is that the answer appears twice. structuredContent is the typed result, the JSON object that validates against the outputSchema from before, the thing a program should read. content is the same data as a block of text. MCP results are always an array of content blocks, and a block can be text, an image, audio, or an embedded resource. The SDK serialized your structured output into a text block as well, because not every client knows how to read structuredContent yet, and the text block is the safe fallback every client understands. That single design decision, returning the answer in two forms, is the kind of thing you would never notice from return out, nil, and the kind of thing that explains a rendering bug six months from now.

The full exchange, the four messages out and the three replies back, is the sequence diagram at the top of this post. That is a complete MCP session.

§Two Promises, Proven on the Wire

I claimed MCP is a negotiated, stateful session. Claims are cheap. The server is right here, so let me check both.

Negotiated. Offer a protocol version the server has never heard of and watch what it does:

{ printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-01-01","capabilities":{},"clientInfo":{"name":"curl-by-hand","version":"0"}}}'
  sleep 1
} | ./wire-demo 2>/dev/null | jq '.result.protocolVersion'
"2025-11-25"

I asked for 2024-01-01. The server did not crash and did not blindly agree. It answered with the version it actually speaks, 2025-11-25, and now the client decides whether it can live with that. That is version negotiation working, and it is why a client built last year can connect to a server built today and the two of them sort out a common language in one message.

Stateful. Skip the handshake entirely and go straight for the tools:

{ printf '%s\n' \
  '{"jsonrpc":"2.0","id":9,"method":"tools/list","params":{}}'
  sleep 1
} | ./wire-demo 2>/dev/null | jq -c .
{"jsonrpc":"2.0","id":9,"error":{"code":0,"message":"method \"tools/list\" is invalid during session initialization"}}

The server refuses. You cannot list tools before you have initialized, because the session has not been established and there is nothing to list against yet. The connection has a state, the handshake moves it forward, and methods are only legal in the right state. That is what stateful means, and it is the single biggest reason MCP is a protocol and not a REST API. The whole next post is about that distinction.

§When You Will Be Glad You Can Read This

None of this replaces the SDK. You will use AddTool in production, and you should. The point is what you gained by spending ten minutes below it.

When a tool result renders fine in one client and breaks in another, you now know to look at the content array versus structuredContent, and you will probably find the second client only reads one of them. When an agent calls your tool with the wrong arguments, you know to read the inputSchema the way the model reads it, because that JSON is the only instruction the model ever got. When you need to secure a server, you understand that the tool descriptions and results in these messages flow straight into a model’s context, which is the entire reason a malicious server is dangerous. When you build an agent gateway, a proxy, or a test harness, you can speak the protocol directly instead of forcing every interaction through a client library that was not built for your shape of problem.

That is the trade. The SDK gives you speed. Reading the wire gives you the ability to debug, secure, and extend, and those are the parts of the job that do not have a convenience method.

txn2/mcp-data-platform, the MCP server this series draws on, is exactly that kind of server: it connects an agent to Trino, DataHub, object storage, and a stack of REST APIs through one MCP endpoint, and cross-injects catalog context into the results instead of handing back raw rows. Building it is the reason these frames are worth reading by hand. The SDK wrote the easy part; the wire is where the work was.

§What’s Next

I proved that MCP is a stateful, negotiated session and not a stack of REST calls, but I have not yet made the case for why it should be. The next post, Why a Protocol, Not an API, builds the same word_count tool as a plain HTTP endpoint and as an MCP server side by side, and counts exactly what discovery, schemas, and the server-initiated direction cost you to hand-roll once you leave the protocol behind.

From there the series goes through every primitive in order, always the same way: the bytes on the wire first, the Go that produces them second. If you can read the four messages above, you can read all of them.


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

← back to all notes