Build an MCP server in Go for Claude Code

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.

Claude Code (MCP client) talks to your Go MCP server over stdio or HTTP; the server calls your API or database Claude Code MCP client MCP server in Go Your API DB, services… stdio / HTTP Go calls The model decides to call a "tool" → the server runs it → returns the result
The MCP server is the adapter between the LLM and your system.

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:

 stdiostreamable HTTP
Executionlocal subprocess launched by the clientstandalone network service
Clientsone, localmany, remote
Authinherited from the machineyour responsibility (token, mTLS…)
Typical usepersonal tool, dev, CLIteam 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.

Comments (0)