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 CV 2026-06-08 · 7 MIN · SHORT-FORM

A Complete MCP Server and Client in Go

One server that uses every primitive, runs over both transports, sits behind auth, and is tested end to end, with one annotated trace through all of it

Diagram · folio cv
sequenceDiagram
  autonumber
  participant C as Client (host)
  participant S as Server (kb)
  C->>S: initialize / initialized
  C->>S: tools/list, resources/list, prompts/list
  C->>S: tools/call summarize_topic
  S->>C: sampling/createMessage
  C->>S: the model's summary
  S->>C: tool result
  C->>S: resources/read kb:///mcp

Every piece of the protocol has been on the table on its own. This post puts them in one server: a small knowledge base that exposes tools, resources, a prompt, and completion, calls back to the client with sampling and elicitation, runs over both transports, sits behind OAuth, and is tested end to end. One server, one session trace, the whole protocol in one place. Reading it should now feel like reading a sentence, because every word has its own post behind it.

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

§One Server, Every Primitive

The server is a NewServer function that wires each primitive in, and the registrations read like a table of contents for the series:

func NewServer() *mcp.Server {
	s := mcp.NewServer(&mcp.Implementation{Name: "kb", Title: "Knowledge Base", Version: "v1.0.0"},
		&mcp.ServerOptions{CompletionHandler: complete})

	mcp.AddTool(s, &mcp.Tool{Name: "summarize_topic", Description: "Summarize a topic with the host model.",
		Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}}, summarizeTopic)   // tool + sampling
	mcp.AddTool(s, &mcp.Tool{Name: "delete_topic", Description: "Delete a topic after confirming."}, deleteTopic) // tool + elicitation

	s.AddResource(&mcp.Resource{URI: "kb://index", Name: "index", MIMEType: "text/plain"}, readIndex)           // resource
	s.AddResourceTemplate(&mcp.ResourceTemplate{URITemplate: "kb:///{topic}", Name: "topic"}, readTopic)        // resource template
	s.AddPrompt(&mcp.Prompt{Name: "explain", Arguments: []*mcp.PromptArgument{{Name: "topic", Required: true}, {Name: "level"}}}, explainPrompt) // prompt

	return s
}

The tools, resources, prompts, and completion posts each covered one of those lines. Here they are one server’s surface. Two of the registrations are worth a second look, because they are where the protocol turns around.

§Two Tools That Call Back

summarize_topic does not summarize anything itself. It reads the topic document and asks the client’s model to summarize it, the sampling inversion, inside a tool handler:

func summarizeTopic(ctx context.Context, req *mcp.CallToolRequest, in topicArg) (*mcp.CallToolResult, any, error) {
	doc := docs[in.Topic]
	res, _ := req.Session.CreateMessage(ctx, &mcp.CreateMessageParams{
		MaxTokens: 100, SystemPrompt: "Summarize in one short sentence.",
		Messages: []*mcp.SamplingMessage{{Role: "user", Content: &mcp.TextContent{Text: doc}}},
	})
	return &mcp.CallToolResult{Content: []mcp.Content{res.Content.(*mcp.TextContent)}}, nil, nil
}

delete_topic does the same trick with elicitation: before it deletes anything, it asks the user to confirm with a form, and acts on the answer. Both tools are short, and both reach back through the connection to something only the host has: its model, and its user.

§Runnable Two Ways

The same NewServer() runs over either transport, chosen by a flag:

server := kb.NewServer()
if !*httpFlag {
	server.Run(context.Background(), &mcp.StdioTransport{}) // stdio: a local subprocess
	return
}
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { return server }, nil)
mux.Handle("/mcp", guard(handler)) // Streamable HTTP, optionally behind a bearer gate
http.ListenAndServe(*addr, mux)

Over stdio it is a subprocess; over Streamable HTTP it is a network service, and adding -auth wraps it in the OAuth bearer gate from the authorization post. The gate works as captured:

no token   -> HTTP 401
valid-token -> HTTP 200

The server logic never changes. The transport and the gate are layers around it, which is the whole point of the protocol being transport-agnostic.

§Tested With an In-Memory Transport

You do not need a subprocess or a port to test an MCP server. The SDK’s InMemoryTransports connects a client and server in one process, speaking the real protocol over a pipe:

func connect(t *testing.T) *mcp.ClientSession {
	server := NewServer()
	client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, &mcp.ClientOptions{
		CreateMessageHandler: func(ctx context.Context, r *mcp.CreateMessageRequest) (*mcp.CreateMessageResult, error) {
			return &mcp.CreateMessageResult{Role: "assistant", Content: &mcp.TextContent{Text: "A short summary."}}, nil
		},
		ElicitationHandler: func(ctx context.Context, r *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
			return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"confirm": true}}, nil
		},
	})
	sc, cc := mcp.NewInMemoryTransports()
	go server.Run(context.Background(), sc)
	session, _ := client.Connect(context.Background(), cc, nil)
	return session
}

The test client supplies the sampling and elicitation handlers a real host would, so the inverted calls resolve. The tests exercise every primitive and pass:

=== RUN   TestTools            --- PASS
=== RUN   TestResources        --- PASS
=== RUN   TestPromptAndCompletion --- PASS
PASS
ok  	example.com/wiredemo/kb	0.009s

TestTools calls summarize_topic and asserts the sampled summary comes back, then calls delete_topic and asserts the confirmed deletion. TestResources reads the index, reads a templated document, and confirms a missing topic errors. TestPromptAndCompletion renders the prompt and completes a topic argument. The whole protocol surface, covered without a process boundary in sight.

§One Session, Annotated

Driving the server through a logging transport produces the trace the series has been building toward, one session, every kind of message, including the inversion happening mid-call:

write: initialize / notifications/initialized
read:  result { capabilities: completions, logging, prompts, resources, tools }
write: tools/list      ->  read: [summarize_topic, delete_topic]
write: resources/list  ->  read: [kb://index]
write: prompts/list    ->  read: [explain]
write: tools/call summarize_topic { topic: "mcp" }
  read:  sampling/createMessage  { the doc to summarize }   <- the server calls back
  write: result { "MCP is a JSON-RPC session protocol for tools." }
read:  result { content: ["MCP is a JSON-RPC session protocol for tools."] }
write: resources/read kb:///mcp  ->  read: contents { the document }

Read the middle of the tool call. The client sent tools/call, and before the result came back, a sampling/createMessage arrived from the server, was answered, and then the tool result followed. A request inside a request, the server borrowing the model while the client waited. Nothing about that frame is mysterious now: it is a server-initiated request, legal because the client declared the sampling capability in the handshake, matched by its own id.

§The Spine, Filled In

The first post promised a table: for every message, which direction it runs, who starts it, and what crosses the boundary. Here it is, complete.

Lifecycle: client to server
initialize
protocol version, capabilities, identity
notifications/initialized
the handshake is complete
Server primitives: client to server
tools/list · tools/call
discover tools and their schemas; invoke one
resources/list · read · templates/list · subscribe
discover, read, parameterize, and watch context
prompts/list · prompts/get
discover and render user-selected templates
completion/complete
autocomplete a prompt or template argument
logging/setLevel
set the minimum log severity
Client primitives: server to client (the inversion)
sampling/createMessage
borrow the host's model
elicitation/create
ask the user, by form or URL
roots/list
ask which directories are in bounds
Notifications: either way, no reply
progress · cancelled · message
request progress, cancellation, structured logs
*/list_changed · resources/updated
a list or a resource has changed
Cross-cutting
ping
liveness; the only request legal before init
tasks/* + task field
call now, fetch later (experimental)
401 + WWW-Authenticate
OAuth 2.1 discovery, on the HTTP transport

Two directions, a handful of methods, three shapes of message underneath all of it. That is the entire protocol.

§This Is the Shape Behind Production

The knowledge base is a toy, but the shape is not. A real data platform is the same picture at scale: a server fronting actual systems, a warehouse, a lake, a catalog, exposing them as tools and resources, calling back for the model and for consent, behind auth, tested in memory before it ever sees a network. That is what txn2/mcp-data-platform is, and what Plexara runs on. The kb server has three documents in a map; swap the map for a query engine and the protocol does not change. The skeleton you just read is the production skeleton.

§Reading the Wire, From Here

The first post made a claim: that the SDK is a convenience, and conveniences leak, and the person who can read the wire ships the fix. Twenty posts later, that floor is yours. You can take any MCP frame, name its shape, say which direction it runs and whether its id belongs there, and tell whether what crossed the boundary is what the model should have seen. The SDK is still a typist that saves you keystrokes, and you should still use it. The difference is that it is no longer magic. It is JSON-RPC 2.0 over a transport, three message shapes, two directions, a short list of methods, and you have read every one of them off the wire.

That is the series. Build the thing, read what it sends, and trust nothing you have not seen on the wire.


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

← back to all notes