Model Context Protocol (MCP) Deep Dive: Building a Server in Go from Scratch
You want your AI agent to query your database, check GitHub PRs, or search Notion docs. Until recently that required a custom integration for every model/tool pair — the classic N×M problem.
Enter the Model Context Protocol (MCP). Introduced by Anthropic in late 2024, MCP is an open standard built on JSON-RPC 2.0 that acts as a universal connector between AI assistants and external data or tools.
In this post, we skip the SDK and build an MCP server from scratch in Go — reading raw JSON-RPC payloads from stdin and writing responses to stdout. This forces you to understand exactly what the protocol requires.
📖 Resources: modelcontextprotocol.io • MCP Specification • Go SDK (for reference)
The N×M Integration Problem
Before MCP, connecting AI models to data sources required N×M custom integrations:
Without MCP: With MCP:
Claude ──► GitHub (custom) Claude ─┐
Claude ──► Postgres (custom) GPT-4 ─┤──► MCP Client ──► GitHub MCP Server
Claude ──► Slack (custom) Cursor ─┘ ──► Postgres MCP Server
GPT-4 ──► GitHub (custom) ──► Slack MCP Server
GPT-4 ──► Postgres (custom)
...11 more integrations N models + M tools = N+M (not N×M)
Because all MCP servers speak the same JSON-RPC dialect, a GitHub MCP server written by anyone works with Claude Desktop, Cursor, and your own agent — instantly.
MCP Architecture
MCP follows a strict Client-Host-Server split:
| Role | Who | Responsibility |
|---|---|---|
| Host | Claude Desktop, Cursor, your agent | Manages the LLM session, user approvals, lifecycle |
| Client | Lives inside the Host | Negotiates protocol, routes JSON-RPC messages |
| Server | Your Go binary | Exposes capabilities (tools, resources, prompts) |
Security boundary: servers never see the full conversation history. They only receive what the client explicitly sends in each request.
Servers expose three primitive types:
- Tools — executable functions (e.g.,
query_database,get_weather) - Resources — readable data (e.g., a file, a DB row)
- Prompts — reusable prompt templates
Transport layers:
- Stdio — for local server binaries (reads
stdin, writesstdout) - HTTP/SSE — for remote, network-accessible servers
The Protocol: JSON-RPC 2.0 over Stdio
MCP over Stdio is just newline-delimited JSON-RPC 2.0. Every message is a single-line JSON object terminated by \n. The full initialization lifecycle for a tool call looks like this:
Client Server
│ │
│── initialize ────────────────►│ (client sends capabilities)
│◄─ result ─────────────────────│ (server replies with its version + capabilities)
│── notifications/initialized ─►│ (client confirms — this is a notification, no response expected)
│ │
│── tools/list ────────────────►│ (discover available tools)
│◄─ result ─────────────────────│ (array of tools with JSON Schemas)
│ │
│── tools/call ────────────────►│ (execute a tool with arguments)
│◄─ result ─────────────────────│ (text/data content)
Two critical details:
notifications/initializedgoes Client → Server, not the other way around. Many blog posts get this backwards.- Notifications have no
idfield and require no response. Requests always have anid.
Building the Server from Scratch
We'll build a server exposing a get_weather tool. Zero external dependencies — only the Go standard library.
1. Setup
mkdir raw-mcp-server && cd raw-mcp-server
go mod init raw-mcp-server
2. JSON-RPC Types & Dispatcher (main.go)
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"os"
)
// ─── JSON-RPC 2.0 base types ────────────────────────────────────────────────
type Request struct {
JSONRPC string `json:"jsonrpc"`
ID *json.Number `json:"id,omitempty"` // *json.Number handles int and string IDs
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type Response struct {
JSONRPC string `json:"jsonrpc"`
ID any `json:"id"`
Result any `json:"result,omitempty"`
Error *RpcError `json:"error,omitempty"`
}
type RpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type Notification struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
}
// ─── Transport ───────────────────────────────────────────────────────────────
// send marshals v and writes exactly one line to stdout.
// IMPORTANT: stdout is the MCP wire — never write debug text here!
func send(v any) {
b, _ := json.Marshal(v)
fmt.Println(string(b))
}
func errorResponse(id any, code int, msg string) {
send(Response{
JSONRPC: "2.0",
ID: id,
Error: &RpcError{Code: code, Message: msg},
})
}
// ─── Main loop ────────────────────────────────────────────────────────────────
func main() {
// Redirect log to stderr — stdout is reserved for JSON-RPC messages.
log.SetOutput(os.Stderr)
log.Println("raw-mcp-server: starting")
scanner := bufio.NewScanner(os.Stdin)
// Default scanner buffer is 64 KB. MCP messages can be larger (e.g., resource
// reads). Increase to 4 MB to be safe.
scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
var req Request
if err := json.Unmarshal(line, &req); err != nil {
log.Printf("parse error: %v", err)
continue
}
// Notifications: no ID, no response required.
if req.ID == nil {
handleNotification(req.Method)
continue
}
id := req.ID // echo back in the response
switch req.Method {
case "initialize":
handleInitialize(id)
case "tools/list":
handleToolsList(id)
case "tools/call":
handleToolsCall(id, req.Params)
default:
// JSON-RPC error code -32601 = Method not found
errorResponse(id, -32601, "method not found: "+req.Method)
}
}
if err := scanner.Err(); err != nil {
log.Fatalf("scanner error: %v", err)
}
}
func handleNotification(method string) {
switch method {
case "notifications/initialized":
// Client confirmed successful initialization. Safe to start serving.
log.Println("client initialized — ready to serve requests")
default:
log.Printf("unhandled notification: %s", method)
}
}
3. Lifecycle Handlers
// ─── initialize ──────────────────────────────────────────────────────────────
// Client sends: { "method": "initialize", "params": { "protocolVersion": "...", ... } }
// Server sends BACK: result with serverInfo + capabilities.
// Then the CLIENT sends notifications/initialized — NOT the server.
func handleInitialize(id any) {
send(Response{
JSONRPC: "2.0",
ID: id,
Result: map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{
"tools": map[string]any{}, // declare tool support
},
"serverInfo": map[string]any{
"name": "raw-weather-mcp",
"version": "1.0.0",
},
},
})
// ✋ DO NOT send notifications/initialized here.
// The client sends that to US after receiving this response.
}
// ─── tools/list ──────────────────────────────────────────────────────────────
// Returns a JSON Schema for each tool. This schema is what the LLM reads
// to understand what inputs your tool requires.
func handleToolsList(id any) {
send(Response{
JSONRPC: "2.0",
ID: id,
Result: map[string]any{
"tools": []map[string]any{
{
"name": "get_weather",
"description": "Fetch the current weather for a specific city.",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{
"city": map[string]any{
"type": "string",
"description": "The city name, e.g. 'Tokyo' or 'Ho Chi Minh City'",
},
},
"required": []string{"city"},
},
},
},
},
})
}
// ─── tools/call ──────────────────────────────────────────────────────────────
// The LLM decided to call a tool with specific arguments.
// We execute it and return content back.
func handleToolsCall(id any, params json.RawMessage) {
var p struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"` // use any, not string — values can be any JSON type
}
if err := json.Unmarshal(params, &p); err != nil {
errorResponse(id, -32602, "invalid params: "+err.Error())
return
}
switch p.Name {
case "get_weather":
city, ok := p.Arguments["city"].(string)
if !ok || city == "" {
errorResponse(id, -32602, "missing required argument: city")
return
}
// In production, call a real weather API here.
text := fmt.Sprintf(
"Weather in %s: 28°C, partly cloudy, humidity 72%%. Wind: 15 km/h NE.",
city,
)
send(Response{
JSONRPC: "2.0",
ID: id,
Result: map[string]any{
"content": []map[string]any{
{"type": "text", "text": text},
},
},
})
default:
errorResponse(id, -32601, "unknown tool: "+p.Name)
}
}
The complete server is ~150 lines of pure Go. No external dependencies, no build tags.
Key Implementation Details
Why *json.Number for the ID?
The MCP spec says the id field can be a string or integer. Using *json.Number in Go means we can unmarshal either type from JSON without losing precision, and echo it back exactly as received — which is required by JSON-RPC.
Stdout is Sacred
Every fmt.Println to stdout is a JSON-RPC message. If you accidentally write a log line like "starting up...", the client will try to parse it as JSON, fail, and abort the session. Always use log.Printf (which prints to stderr by default, though we explicitly set it).
The Scanner Buffer
bufio.Scanner has a default max line size of 64 KB. An MCP resources/read response returning a large file can easily exceed this. The 4 MB buffer is a safe default for most use cases.
Testing
Build and run with the MCP Inspector (no Claude Desktop needed):
go build -o raw-weather-server .
npx @modelcontextprotocol/inspector ./raw-weather-server
The Inspector opens a web UI where you can:
- Trigger the initialization handshake
- Call
tools/listand see your schema - Call
get_weatherwith{"city": "Hanoi"}and see the response
You can also test by piping raw JSON manually:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}' | ./raw-weather-server
Expected output:
{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":{}},"protocolVersion":"2024-11-05","serverInfo":{"name":"raw-weather-mcp","version":"1.0.0"}}}
Integrating with Claude Desktop
Add your binary to the Claude Desktop config at ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"weather": {
"command": "/absolute/path/to/raw-mcp-server/raw-weather-server"
}
}
}
Restart Claude Desktop and ask: "What's the weather like in Hanoi?"
Behind the scenes, Claude:
- Sends
initializeto your binary viastdin - Calls
tools/listto discoverget_weather - Sends
tools/callwith{"city": "Hanoi"} - Reads the text response from
stdoutand includes it in its reply
Raw vs SDK — When to Use Which
| From Scratch | Official Go SDK | |
|---|---|---|
| Zero dependencies | ✅ | ❌ (external package) |
| Protocol control | Full | Abstracted |
| Type safety | Manual | Auto via struct reflection |
| Schema generation | Manual JSON | Auto from struct tags |
| Error handling | Manual JSON-RPC errors | Handled |
| Best for | Learning, minimal binaries, embedded systems | Production servers |
Key Takeaways
- MCP is newline-delimited JSON-RPC 2.0 — there's no binary encoding, no magic. You can test it with
echoand a pipe. notifications/initializedflows Client → Server, not the reverse. The server only sends theinitializeresult.- Stdout is a wire, not a console. Any stray character corrupts the session.
- The
inputSchemaintools/listis what the LLM reads to understand your tool. Write it carefully — it's essentially documentation for the model. - Building from scratch is the best way to understand the protocol before reaching for an SDK.