cd /news/developer-tools/build-an-mcp-server-in-go-for-claude… · home topics developer-tools article
[ARTICLE · art-55333] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Build an MCP Server in Go (for Claude Code)

A developer built an MCP (Model Context Protocol) server in Go to allow Claude Code to query an internal billing API without manual copy-pasting. The server uses the official Go SDK, which matured in 2025, and the developer emphasizes the importance of choosing between stdio and HTTP transports based on whether the tool is personal or shared. The key insight is that tool descriptions, not prompts, define the interface for the LLM.

read6 min views1 publishedJul 11, 2026

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 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.

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.

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))

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.

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.

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.

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/build-an-mcp-server-…] indexed:0 read:6min 2026-07-11 ·