Skip to main content

Model Context Protocol (MCP) Deep Dive: Building a Server in Go from Scratch

· 8 min read
Hieu Nguyen
Senior Software Engineer at OCB

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.ioMCP SpecificationGo 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:

RoleWhoResponsibility
HostClaude Desktop, Cursor, your agentManages the LLM session, user approvals, lifecycle
ClientLives inside the HostNegotiates protocol, routes JSON-RPC messages
ServerYour Go binaryExposes 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, writes stdout)
  • 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:

  1. notifications/initialized goes Client → Server, not the other way around. Many blog posts get this backwards.
  2. Notifications have no id field and require no response. Requests always have an id.

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:

  1. Trigger the initialization handshake
  2. Call tools/list and see your schema
  3. Call get_weather with {"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:

  1. Sends initialize to your binary via stdin
  2. Calls tools/list to discover get_weather
  3. Sends tools/call with {"city": "Hanoi"}
  4. Reads the text response from stdout and includes it in its reply

Raw vs SDK — When to Use Which

From ScratchOfficial Go SDK
Zero dependencies❌ (external package)
Protocol controlFullAbstracted
Type safetyManualAuto via struct reflection
Schema generationManual JSONAuto from struct tags
Error handlingManual JSON-RPC errorsHandled
Best forLearning, minimal binaries, embedded systemsProduction servers

Key Takeaways

  1. MCP is newline-delimited JSON-RPC 2.0 — there's no binary encoding, no magic. You can test it with echo and a pipe.
  2. notifications/initialized flows Client → Server, not the reverse. The server only sends the initialize result.
  3. Stdout is a wire, not a console. Any stray character corrupts the session.
  4. The inputSchema in tools/list is what the LLM reads to understand your tool. Write it carefully — it's essentially documentation for the model.
  5. Building from scratch is the best way to understand the protocol before reaching for an SDK.