105 2026-06-08 · 6 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 · 105
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, with one annotated session trace through all of it.

This is the finale 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 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 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 one session with 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. That frame 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. That skeleton is the production skeleton.

§What Crossed the Wire

The first post made a claim: the SDK is a convenience, and conveniences leak, and the person who can read the wire ships the fix. MCP is JSON-RPC 2.0 over a transport, three message shapes, two directions, a short list of methods. This series read every one of them off the wire. The SDK is still a typist that saves keystrokes, and I still use it. It is not the protocol.


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

← back to all notes