Claude Code can read your repo, run your CLI, query your database — as long as you give it the entry point. That entry point is an MCP server (Model Context Protocol). The day I wanted Claude to query our internal billing API without me copy-pasting JSON responses by hand, I wrote a small MCP server. In Go, not Python — and in hindsight that was the right call, for one specific reason we'll get to.
The official Go SDK (github.com/modelcontextprotocol/go-sdk) matured in 2025. It's perfectly usable in production now, but the docs still center on the "hello world." Here's what actually matters once the server leaves your machine: the transport choice, typed tools, auth, and the design traps I walked straight into.
MCP in 30 seconds: a server that exposes tools to an LLM
MCP is a client-server protocol. The client (Claude Code, Claude Desktop, your own agent) connects to one or more servers, each exposing three things: tools (functions the model can call), resources (data it can read), and prompts (reusable templates). 90% of real-world use is tools.
The key point: you don't write a prompt. You declare tools with a name, a description and an input schema. The model reads those descriptions and decides on its own when to call them. The quality of your descriptions is your interface.
The minimal server with the official SDK
Three steps: create the server, register a tool with its typed handler, run it on a transport. The SDK infers the tool's JSON schema directly from your Go struct.
package main
import (
"context"
"log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// The tool input: the SDK derives the JSON schema exposed to the model from it.
type InvoiceArgs struct {
ClientID string `json:"client_id" jsonschema:"the client account ID"`
Year int `json:"year" jsonschema:"fiscal year, e.g. 2026"`
}
func getInvoices(ctx context.Context, req *mcp.CallToolRequest, args InvoiceArgs) (*mcp.CallToolResult, any, error) {
rows, err := billing.Lookup(ctx, args.ClientID, args.Year) // your real code
if err != nil {
return nil, nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: rows.Markdown()}},
}, nil, nil
}
func main() {
server := mcp.NewServer(&mcp.Implementation{
Name: "billing",
Version: "v1.0.0",
}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "get_invoices",
Description: "List a client's invoices for a given fiscal year.",
}, getInvoices)
// stdio transport: Claude Code launches this binary as a subprocess
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}
On the Claude Code side, you declare this server in the MCP config (command + arguments). At startup, Claude launches the binary, negotiates the protocol, and discovers get_invoices. No prompt to write: the description is enough.
stdio or streamable HTTP: the choice that drives everything else
The SDK supports two transports, and this is the architecture decision. They don't serve the same use case:
| stdio | streamable HTTP | |
|---|---|---|
| Execution | local subprocess launched by the client | standalone network service |
| Clients | one, local | many, remote |
| Auth | inherited from the machine | your responsibility (token, mTLS…) |
| Typical use | personal tool, dev, CLI | team server, SaaS, shared prod |
The simple rule: stdio for a tool only you use on your machine, HTTP as soon as it's shared. The classic trap is to prototype in stdio then want to expose it to the team without realizing you're moving from a "zero auth" model to a "network attack surface." The HTTP transport plugs in almost like a regular HTTP handler — which means all the middleware best practices (recover, timeout, auth, logs) apply:
handler := mcp.NewStreamableHTTPHandler(
func(r *http.Request) *mcp.Server { return server },
nil,
)
// wrap it with the same middleware as any API
http.ListenAndServe(":8080", Chain(handler, Recover, Auth, Logger))
Typed tools: let Go's types do the work
This is where Go beats Python for this particular job. The JSON schema exposed to the model is derived from your input struct. No hand-written schema, no drift between validation and docs:
// ❌ manual schema: drifts from the code, validates nothing
tool := &mcp.Tool{
Name: "search",
InputSchema: rawJSON(`{"type":"object","properties":{"q":{"type":"string"}}}`),
}
// ✅ typed struct: schema inferred, validation for free, one source of truth
type SearchArgs struct {
Query string `json:"query" jsonschema:"full-text search query"`
Limit int `json:"limit" jsonschema:"max results, default 10"`
}
When the model sends arguments, the SDK validates them against the schema before calling your handler. A year sent as a string? Rejected before your code runs. It's exactly the Go philosophy of making invalid state impossible, applied at the boundary with the LLM — the least reliable layer of your system.
Auth and security: an MCP server is an attack surface
An MCP server runs code on a model's request, and that model is driven by a potentially adversarial prompt (prompt injection). Three rules I set for myself after the fact:
1. Least privilege per tool. Don't expose run_sql with write access "just in case." Expose get_invoices(client_id, year) — bounded, read-only. Each tool is a capability granted to the model — treat it like a public API route.
2. Over HTTP, auth is non-negotiable. A bearer token checked in a middleware before reaching the MCP server, like any API. An MCP server open on the network with no auth is self-service RCE.
3. The context is your cancellation thread. If the client disconnects, ctx is cancelled — propagate it down to your DB calls. An MCP handler that ignores ctx.Done() is a goroutine leak waiting to happen under load.
The design mistakes I made
Tools too granular. My first version exposed get_client, get_invoice, get_line_item separately. The model chained five calls where a single well-designed get_client_summary would do — and every call costs a round-trip and context tokens. Design your tools for the model's task, not for your database schema.
Responses too big. Returning 2,000 lines of raw JSON saturates the context window and makes the model (and the bill) pay for nothing. Summarize, paginate, return readable Markdown instead of a dump. A tool result isn't a REST response: it's reasoning material for a human-model.
Vague descriptions. "search" says nothing. "Full-text search across invoices, returns up to limit matches sorted by date" tells the model when and how to call it. Your descriptions are literally your tool's system prompt — treat them like copywriting.
Conclusion
Writing an MCP server in Go isn't writing AI — it's writing a clean, typed, secure API whose only client happens to be a model. Everything you already know about good Go services applies: strict types at the boundary, propagated context, middleware, least privilege. MCP just adds a new category of client, the most unpredictable of them all.
And that's exactly why Go wins here: facing a non-deterministic caller, you want the maximum number of guarantees at compile time. Python lets you write the server faster; Go lets you sleep at night when the model calls it in a way you never anticipated.