83 2026-05-01 · 13 MIN · LONG-FORM

Drive an MCP Server With Four JSON Messages

The Go SDK never prints the protocol. This post does.

Diagram · 83
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. The SDK delivers that in about a dozen lines. You never see the JSON, until something breaks.

This is the opening of MCP Without the SDK, a series that takes the Model Context Protocol apart one JSON message at a time, 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 the raw JSON, checked 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, in the JSON the SDK hides from you. The SDK is a convenience, and conveniences leak. When yours does, the person who can read that JSON ships the fix, and the person who cannot files an issue and waits.

This series is those messages. MCP is small enough to understand completely, and once you can see them, the SDK stops being opaque 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 JSON 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 JSON 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, the spec this series reads, 2025-11-25, still has a lifecycle. It was modeled on the Language Server Protocol: a host (your app, the thing with the model) runs a client, the client talks to a server (your tool provider), and on this stdio transport that starts with initialize. Skip it and this SDK server refuses tools/list. That is real, and the JSON at the end shows it. It is not a law of MCP.

stdio is a subprocess pipe, so the connection is long-lived whether the protocol wants it or not. Streamable HTTP in the same revision already treated sessions as optional: a server MAY mint an Mcp-Session-Id, and a server that does not can take each POST on its own. The 2026-07-28 spec dropped the handshake and the session header from the protocol core. Version, client info, and capabilities travel on the request. Application state, when you need it, is a handle you pass as a tool argument, a basket_id, not a transport secret. This demo is stateful because we are on stdio and on 2025-11-25. Do not take that as the shape of the protocol.

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
        }
      }
    ]
  }
}

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

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 Checks Against This Server

I claimed this 2025-11-25 stdio server negotiates a version and refuses work before initialize. Claims are cheap. The binary 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.

Initialize first, on this server. Skip the handshake 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. Under 2025-11-25, on this SDK, tools/list is illegal until initialize has finished. That is lifecycle state on this transport, and it is worth seeing once so the error message makes sense when you hit it. It is not why MCP exists, and it is not where the protocol stayed. HTTP sessions were already optional in this revision. 2026-07-28 removed initialize and Mcp-Session-Id from the core. The next post is about the part that did not go away: discovery, schemas, and the server being able to ask something mid-call.

§Where the SDK Stops Helping

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.

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

This stdio server negotiates a version and wants initialize before tools/list. That is 2025-11-25 on a pipe. The next post, MCP Is a Protocol, Not a REST API, builds the same word_count tool as a plain HTTP endpoint and as an MCP server side by side, and counts what discovery, schemas, and a mid-call callback cost you to hand-roll once you leave the protocol behind. Those are the reasons to keep MCP even after the protocol stopped carrying a session for you.

From there the series goes through every primitive in order, always the same way: the JSON first, the Go that produces it 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