← Back to Blog

Build a custom MCP server for Claude Code in Go

mcp claude-code go developer-tools bbolt mcp-go
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_totals tool 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_lead or add_install tool that enforces the schema and validation, and every write is identical.
  • Bounded, searchable surfaces. Instead of dumping a whole table into context, a search tool 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 as bolt and never reach for the deprecated github.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 limit to 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.Println corrupts the stream. Write every log line to stderr.
  • Keep construction transport-agnostic. internal/server builds 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.