{"slug": "build-an-mcp-server-in-go-for-claude-code", "title": "Build an MCP Server in Go (for Claude Code)", "summary": "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.", "body_md": "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.\n\nThe official Go SDK (`github.com/modelcontextprotocol/go-sdk`\n\n) 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.\n\nMCP 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.\n\nClaude 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\n\nThe MCP server is the adapter between the LLM and your system.\n\nThe 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.\n\nThree 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.\n\n```\npackage main\n\nimport (\n    \"context\"\n    \"log\"\n\n    \"github.com/modelcontextprotocol/go-sdk/mcp\"\n)\n\n// The tool input: the SDK derives the JSON schema exposed to the model from it.\ntype InvoiceArgs struct {\n    ClientID string `json:\"client_id\" jsonschema:\"the client account ID\"`\n    Year     int    `json:\"year\"      jsonschema:\"fiscal year, e.g. 2026\"`\n}\n\nfunc getInvoices(ctx context.Context, req *mcp.CallToolRequest, args InvoiceArgs) (*mcp.CallToolResult, any, error) {\n    rows, err := billing.Lookup(ctx, args.ClientID, args.Year) // your real code\n    if err != nil {\n        return nil, nil, err\n    }\n    return &mcp.CallToolResult{\n        Content: []mcp.Content{&mcp.TextContent{Text: rows.Markdown()}},\n    }, nil, nil\n}\n\nfunc main() {\n    server := mcp.NewServer(&mcp.Implementation{\n        Name:    \"billing\",\n        Version: \"v1.0.0\",\n    }, nil)\n\n    mcp.AddTool(server, &mcp.Tool{\n        Name:        \"get_invoices\",\n        Description: \"List a client's invoices for a given fiscal year.\",\n    }, getInvoices)\n\n    // stdio transport: Claude Code launches this binary as a subprocess\n    if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {\n        log.Fatal(err)\n    }\n}\n```\n\nOn 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`\n\n. No prompt to write: the description is enough.\n\nThe SDK supports two transports, and this is **the** architecture decision. They don't serve the same use case:\n\nstdio\n\nstreamable HTTP\n\nExecution\n\nlocal subprocess launched by the client\n\nstandalone network service\n\nClients\n\none, local\n\nmany, remote\n\nAuth\n\ninherited from the machine\n\n**your responsibility** (token, mTLS…)\n\nTypical use\n\npersonal tool, dev, CLI\n\nteam server, SaaS, shared prod\n\nThe 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:\n\n```\nhandler := mcp.NewStreamableHTTPHandler(\n    func(r *http.Request) *mcp.Server { return server },\n    nil,\n)\n// wrap it with the same middleware as any API\nhttp.ListenAndServe(\":8080\", Chain(handler, Recover, Auth, Logger))\n```\n\nThis 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:\n\n```\n// ❌ manual schema: drifts from the code, validates nothing\ntool := &mcp.Tool{\n    Name: \"search\",\n    InputSchema: rawJSON(`{\"type\":\"object\",\"properties\":{\"q\":{\"type\":\"string\"}}}`),\n}\n\n// ✅ typed struct: schema inferred, validation for free, one source of truth\ntype SearchArgs struct {\n    Query string `json:\"query\" jsonschema:\"full-text search query\"`\n    Limit int    `json:\"limit\" jsonschema:\"max results, default 10\"`\n}\n```\n\nWhen the model sends arguments, the SDK validates them against the schema *before* calling your handler. A `year`\n\nsent 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.\n\nAn 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:\n\n**1. Least privilege per tool.** Don't expose `run_sql`\n\nwith write access \"just in case.\" Expose `get_invoices(client_id, year)`\n\n— bounded, read-only. Each tool is a capability granted to the model — treat it like a public API route.\n\n**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.\n\n**3. The context is your cancellation thread.** If the client disconnects, `ctx`\n\nis cancelled — propagate it down to your DB calls. An MCP handler that ignores `ctx.Done()`\n\nis a [goroutine leak](https://www.web-developpeur.com/en/blog/goroutine-leaks-golang) waiting to happen under load.\n\n**Tools too granular.** My first version exposed `get_client`\n\n, `get_invoice`\n\n, `get_line_item`\n\nseparately. The model chained five calls where a single well-designed `get_client_summary`\n\nwould do — and every call costs a round-trip and context tokens. Design your tools for *the model's task*, not for your database schema.\n\n**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.\n\n**Vague descriptions.** \"search\" says nothing. \"Full-text search across invoices, returns up to `limit`\n\nmatches 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.\n\nWriting 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.\n\nAnd 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.", "url": "https://wpnews.pro/news/build-an-mcp-server-in-go-for-claude-code", "canonical_source": "https://dev.to/ohugonnot/build-an-mcp-server-in-go-for-claude-code-2o2i", "published_at": "2026-07-11 09:00:03+00:00", "updated_at": "2026-07-11 09:13:56.453644+00:00", "lang": "en", "topics": ["developer-tools", "large-language-models", "ai-agents"], "entities": ["Claude Code", "Model Context Protocol", "Go SDK", "billing API"], "alternates": {"html": "https://wpnews.pro/news/build-an-mcp-server-in-go-for-claude-code", "markdown": "https://wpnews.pro/news/build-an-mcp-server-in-go-for-claude-code.md", "text": "https://wpnews.pro/news/build-an-mcp-server-in-go-for-claude-code.txt", "jsonld": "https://wpnews.pro/news/build-an-mcp-server-in-go-for-claude-code.jsonld"}}