# Build an MCP Server in Go (for Claude Code)

> Source: <https://dev.to/ohugonnot/build-an-mcp-server-in-go-for-claude-code-2o2i>
> Published: 2026-07-11 09:00:03+00:00

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](https://www.web-developpeur.com/en/blog/go-middleware-best-practices-production) — 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](https://www.web-developpeur.com/en/blog/interfaces-go-philosophie-accept-return), 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](https://www.web-developpeur.com/en/blog/goroutine-leaks-golang) 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.
