Build a custom MCP server for Claude Code in Go
A custom MCP server turns fuzzy, token-expensive LLM work — aggregates, calculations, and consistent database writes — into one deterministic tool call. Instead of asking the model to shell out, read text, and guess, you give it a typed tool with a described schema and a validated result. And you can build one as a single Go binary in an afternoon.
This is a how-to. We ship this exact pattern in binzaar, our open-source micro-app scaffold: one Go binary that is at once an MCP stdio server, a terminal UI, and an embedded database over a single use-case layer. Below is how to build your own, step by step, with the real mark3labs/mcp-go and go.etcd.io/bbolt APIs we use.
Why an MCP server beats a CLI tool or raw prompting
An MCP tool gives the model a typed schema, validated inputs, and a structured result. A CLI tool gives it a guessing game. When Claude Code drives a CLI, it has to remember the flags, run the binary, parse free-text stdout, and hope the parse held. Every one of those steps costs tokens and can fail silently.
With MCP, the model calls one described function and gets JSON back. Fewer tokens, fewer round-trips, and output the model can’t fumble.
flowchart TB
subgraph CLI["CLI tool — the model shells out"]
direction TB
M1["Claude Code"] --> G1["guess flags"] --> R1["run binary"] --> P1["parse stdout text"] --> X1["hope it parsed"]:::bad
end
subgraph MCP["MCP tool — one typed call"]
direction TB
M2["Claude Code"] --> C2["call add_lead(name, email)"] --> V2["schema validates input"] --> S2["structured JSON result"]:::good
end
classDef good fill:#494fdf,stroke:#376cd5,color:#ffffff,stroke-width:2px
classDef bad fill:#1e293b,stroke:#64748b,color:#f1f5f9,stroke-width:1.5px
There is a second reason: .mcp.json is a shared standard. Claude Code reads it, and so do other MCP clients. One server you write serves every tool that speaks the protocol — you are not building a Claude-only integration.
What an MCP server actually solves
The core win is efficiency and consistency. Some work is expensive for an LLM to do by prompting and cheap for a small binary to do correctly. Move that work into a tool.
Three cases pay off immediately:
- Aggregates and calculations. Summing, grouping, or ranking hundreds of rows in the prompt burns tokens and invites arithmetic mistakes. A
report_totalstool returns the number. - Database writes with model consistency. This is the big one. If you let the model hand-write rows, it drifts — a field renamed here, a type coerced there. Give it one
create_leadoradd_installtool that enforces the schema and validation, and every write is identical. - Bounded, searchable surfaces. Instead of dumping a whole table into context, a
searchtool takes a query and returns a page. The model sees only what it asked for.
Determinism is the point. The same call with the same input gives the same result, and the model can’t invent a shape you didn’t define.
Bootstrap the Go project
Start from a template so you get the layering right on day one. binzaar init drops an embedded Claude Code kit into your directory: a .claude/ with the rules that matter (mcp-server.md, db-rules.md) and a spec-driven workflow — /product-idea writes the spec, /app-init scaffolds the Go module, /app-spec-sync keeps code and spec in step.
The shape is one domain behind three faces, all backed by the same single file:
flowchart TB
DB["embedded bbolt db<br/>(single file, no server)"]:::core
UC["use-case layer<br/>(your verbs)"]:::core
DB --> UC
UC --> MCP["MCP stdio server<br/>(Claude Code, other clients)"]:::face
UC --> TUI["terminal UI<br/>(humans)"]:::face
classDef core fill:#494fdf,stroke:#376cd5,color:#ffffff,stroke-width:2px
classDef face fill:#1e293b,stroke:#64748b,color:#f1f5f9,stroke-width:1.5px
Two libraries carry the weight:
- MCP:
github.com/mark3labs/mcp-go— protocol types and the server object. - Storage:
go.etcd.io/bbolt— an embedded, single-file, ACID key/value store. Pure Go, no server, no cgo. Alias it asboltand never reach for the deprecatedgithub.com/boltdb/bolt.
The result is one binary with a serve (or mcp) mode that runs the stdio server, plus a default mode for the TUI. main stays thin; cmd/ parses the mode and opens the one database file.
Describe your need as tools
Name your use-cases as verbs, then make each verb a tool. Ask one question first: what does the model need to do that is expensive to do by prompting? Each answer is a tool with a described schema.
Always set a description on the tool and on every parameter — the model relies on those strings to know when and how to call it. Here is a real add_lead tool with the error convention we follow:
package server
import ( "context"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server")
// New builds a transport-agnostic server; cmd/ picks the transport.func New(name, version string, h *handler) *server.MCPServer { s := server.NewMCPServer(name, version, server.WithToolCapabilities(true), server.WithRecovery(), // a panicking handler can't crash the process )
s.AddTool(mcp.NewTool("add_lead", mcp.WithDescription("Create one lead in the database with validated fields."), mcp.WithString("name", mcp.Required(), mcp.Description("Full name of the lead")), mcp.WithString("email", mcp.Required(), mcp.Description("Contact email")), mcp.WithString("source", mcp.Enum("web", "referral", "event"), mcp.Description("Where the lead came from")), ), h.addLead)
return s}
func (h *handler) addLead(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { name, err := req.RequireString("name") if err != nil { return mcp.NewToolResultError(err.Error()), nil // user error → result, nil } email, err := req.RequireString("email") if err != nil { return mcp.NewToolResultError(err.Error()), nil }
lead, err := h.app.CreateLead(ctx, name, email, req.GetString("source", "web")) if err != nil { return nil, err // infrastructure error → nil, err } return mcp.NewToolResultJSON(lead)}That error split matters. A bad input or a broken business rule returns NewToolResultError(...), nil — a result the model can read and correct. A failure the model can’t fix (the DB is down, the disk is full) returns nil, err as a real protocol error. Mixing these up either hides real faults or dead-ends the model on a mistake it could recover from.
Tune the tool surface
Once a tool takes more than one or two arguments, stop reading raw params and switch to a typed handler. Define an input struct with jsonschema tags, register it with mcp.WithInputSchema[T](), and wrap the handler in mcp.NewStructuredToolHandler(fn) — the input is validated and bound before your code runs.
type SearchRequest struct { Query string `json:"query" jsonschema:"required,description=Search text"` Limit int `json:"limit" jsonschema:"description=Max results, default 20"`}Three rules keep a server honest under an agent:
- List tools page — never dump. Give every list tool a query, an order, and a bounded page size. Clamp an oversized
limitto the max instead of rejecting it; the model shouldn’t have to retry to learn your ceiling. - Log to stderr, never stdout. On stdio, stdout is the protocol channel. A stray
fmt.Printlncorrupts the stream. Write every log line to stderr. - Keep construction transport-agnostic.
internal/serverbuilds and registers tools with no mention of a transport.cmd/selects stdio:
// cmd/ selects the transport; internal/server stays transport-agnostic.if err := server.ServeStdio(s); err != nil { fmt.Fprintln(os.Stderr, "mcp server:", err) // stderr, not stdout os.Exit(1)}Wire it into .mcp.json
Claude Code discovers your server from a project-local .mcp.json. It holds an mcpServers map; each entry is a stdio launch — a command and its args — pointing at your built binary in its serve mode:
{ "mcpServers": { "leaddesk": { "command": "/home/you/.local/share/binzaar/bin/leaddesk", "args": ["serve"] } }}Drop that file in the project root, restart Claude Code, and your tools appear. Because the file is a shared standard, any other MCP client in that project reads the same entry. Point command at an absolute path to the binary; the serve arg is what flips it from TUI mode into the stdio server.
Optional: let binzaar scaffold and wire it for you
If you’d rather not do this by hand, binzaar can. It scaffolds a brand-new micro-app from a template and hands off to the /product-idea spec workflow, and its configure_mcp step writes exactly the .mcp.json entry above for an installed app — adding or replacing that one server key while leaving every other key in the file untouched. The code and rules are open source at github.com/Techthos/binzaar.
The takeaway holds either way: a custom MCP server converts expensive, drift-prone prompting into one deterministic tool call, and a single Go binary with an embedded database is enough to ship it. Start with the one verb that costs you the most tokens today, and give the model a tool for it.
If you’d like a hand designing the tool surface for your own stack, get in touch.