WRITING April 18, 2026 24 min read

Building MCP Servers in Go: The Complete Guide

Every existing MCP tutorial gets you to a hello-world stdio server and stops. This is the rest of it: SDK choice, transports, testing, production patterns, and what I learned shipping four MCP servers that handle real workloads.

Every tutorial I’ve seen for building an MCP server in Go follows the same arc. Install an SDK, register a tool that adds two numbers, connect it to Claude Desktop, screenshot the result, done. That gets you from zero to “it works” in about ten minutes, and then you’re on your own for everything that matters: which SDK to actually use, how transports work, how to test any of it, what production code looks like versus demo code. I’ve shipped four MCP servers in Go (scry, tome, lore, flume) and the gap between the tutorials and what I needed to know was large enough that I figured it was worth writing down.

This post is the whole thing. From “what is MCP” through production deployment, with working code at every step and honest notes about which parts are clean and which parts are not.

What MCP is, briefly

The Model Context Protocol is a spec for how AI agents talk to external tools. It’s a JSON-RPC 2.0 protocol with a specific set of methods, a capability negotiation handshake, and three primitives that a server can expose: tools, resources, and prompts. If you’ve built a REST API, the mental model is similar, except the client is an LLM agent rather than a browser, and the protocol handles things like capability discovery and structured error codes that REST leaves to convention.

The spec is maintained by Anthropic. The reference implementations are in TypeScript and Python. Go is a first-class citizen as of mid-2025 when an official SDK landed, though the community had already built two solid alternatives by then. More on that in a second.

The important thing to understand is that MCP is a wire protocol, not a framework. Your server is a normal Go program that speaks JSON-RPC over one of three transports (stdio, SSE, or streamable HTTP). Everything else — how you structure your code, how you handle errors, how you deploy — is up to you.

Choosing an SDK

There are three Go SDKs worth knowing about. This is the part that nobody writes about because most tutorials just pick one and move on, but the choice matters and it’s not obvious.

The official SDK: modelcontextprotocol/go-sdk

The official SDK is maintained under the MCP GitHub org with significant contributions from Google. It landed in mid-2025 and it’s the one Anthropic points to in their docs.

import (
    "github.com/modelcontextprotocol/go-sdk/mcp"
    "github.com/modelcontextprotocol/go-sdk/server"
)

s := server.NewMCPServer(
    "my-server",
    "1.0.0",
    server.WithToolCapabilities(true),
)

s.AddTool(
    mcp.NewTool("greet",
        mcp.WithDescription("Greet someone by name"),
        mcp.WithString("name",
            mcp.Required(),
            mcp.Description("The person's name"),
        ),
    ),
    func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        name := req.Params.Arguments["name"].(string)
        return mcp.NewToolResultText(fmt.Sprintf("Hello, %s", name)), nil
    },
)

The API is functional-options style, which is idiomatic Go but verbose. Tool parameters are defined inline with helper functions like mcp.WithString, mcp.Required(), mcp.Description(). It works, but for a tool with six parameters you’re looking at a lot of nesting.

The official SDK supports all three transports: stdio, SSE, and streamable HTTP. It has built-in auth support via an auth package. The types map closely to the MCP spec, which means if you’re reading the spec alongside the code, everything lines up.

The community SDK: mark3labs/mcp-go

mcp-go predates the official SDK by several months and has significantly more community traction — about 3,000 dependents on GitHub at time of writing. It’s the one most existing tutorials use.

import (
    "github.com/mark3labs/mcp-go/mcp"
    "github.com/mark3labs/mcp-go/server"
)

s := server.NewMCPServer(
    "my-server",
    "1.0.0",
    server.WithToolCapabilities(true),
)

tool := mcp.NewTool("greet",
    mcp.WithDescription("Greet someone by name"),
    mcp.WithString("name",
        mcp.Required(),
        mcp.Description("The person's name"),
    ),
)

s.AddTool(tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
    name := req.GetArguments()["name"].(string)
    return mcp.NewToolResultText(fmt.Sprintf("Hello, %s", name)), nil
})

If those two snippets look nearly identical, that’s because they are. The APIs converged over time and the core patterns are interchangeable. The differences are mostly in the edges: how auth works, how middleware is structured, which transport options are available, and how well the types track the latest spec revision.

The third option: mcp-golang

mcp-golang by Metoro takes a different approach. Instead of defining tool parameters with builder functions, you define a Go struct and the SDK reflects on it to generate the JSON Schema:

type GreetArgs struct {
    Name string `json:"name" description:"The person's name"`
}

tool := mcp_golang.NewTool("greet",
    mcp_golang.WithDescription("Greet someone by name"),
    mcp_golang.WithArgs(GreetArgs{}),
)

This is genuinely nice for tools with many parameters. The struct tags carry the schema, and your handler receives a typed struct instead of a map[string]interface{}. The tradeoff is that it’s the smallest of the three communities, and it tracks spec changes on a longer delay.

Which one to use

I use the official SDK for new projects. The reasoning is straightforward: it tracks the spec fastest, it has Anthropic’s backing, and the API is close enough to mcp-go that switching is low-cost. The community SDK is fine too — if you have existing code on mcp-go, there’s no urgent reason to migrate. The APIs are similar enough that the choice is more about ecosystem alignment than technical capability.

The one thing I’d avoid is starting a new project on mcp-golang unless struct-tag schema generation is genuinely important to you. It’s a good idea executed well, but the smaller community means you’ll be reading source code instead of Stack Overflow answers when something breaks.

For the rest of this post I’ll use the official SDK. Everything conceptual applies to all three.

Your first server

Here’s a complete, working MCP server. Not a two-number adder — a file-reading tool that actually does something useful. Save this as main.go:

package main

import (
    "context"
    "fmt"
    "os"

    "github.com/modelcontextprotocol/go-sdk/mcp"
    "github.com/modelcontextprotocol/go-sdk/server"
)

func main() {
    s := server.NewMCPServer(
        "file-reader",
        "0.1.0",
        server.WithToolCapabilities(true),
    )

    s.AddTool(
        mcp.NewTool("read_file",
            mcp.WithDescription("Read a file and return its contents"),
            mcp.WithString("path",
                mcp.Required(),
                mcp.Description("Absolute path to the file"),
            ),
        ),
        handleReadFile,
    )

    if err := server.ServeStdio(s); err != nil {
        fmt.Fprintf(os.Stderr, "server error: %v\n", err)
        os.Exit(1)
    }
}

func handleReadFile(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
    path, ok := req.Params.Arguments["path"].(string)
    if !ok || path == "" {
        return mcp.NewToolResultError("path is required"), nil
    }

    data, err := os.ReadFile(path)
    if err != nil {
        return mcp.NewToolResultError(fmt.Sprintf("failed to read file: %v", err)), nil
    }

    return mcp.NewToolResultText(string(data)), nil
}

Build and test it:

go mod init file-reader
go mod tidy
go build -o file-reader .

The binary speaks MCP over stdio. You can test it manually by piping JSON-RPC messages to it, but the easier path is to wire it into Claude Desktop or Claude Code and let the client handle the protocol.

Connecting to Claude Desktop

Add this to your Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json on macOS):

{
  "mcpServers": {
    "file-reader": {
      "command": "/absolute/path/to/file-reader"
    }
  }
}

Restart Claude Desktop. You should see “file-reader” in the MCP server list. Ask it to read a file and it’ll call your tool.

Connecting to Claude Code

Claude Code picks up MCP servers from its settings. Run:

claude mcp add file-reader /absolute/path/to/file-reader

That’s it. Claude Code will spawn your binary as a subprocess and talk to it over stdio.

What just happened

When Claude Desktop or Claude Code starts your server, the MCP client sends an initialize request. Your server responds with its capabilities (in this case, just tools). The client then sends tools/list to discover what tools are available. When the agent decides to use your tool, it sends tools/call with the tool name and arguments. Your handler runs, returns a result, and the agent incorporates it into its response.

The whole exchange is JSON-RPC 2.0 over stdin/stdout. Your server reads from stdin, writes to stdout, and stderr is available for logging (the client ignores it). This is the stdio transport, the simplest of the three. More on the other two in the transport section.

The three primitives

MCP servers can expose three types of things: tools, resources, and prompts. Most tutorials only cover tools, but the other two are worth understanding because they solve different problems and using the wrong primitive for the job creates friction.

Tools

Tools are functions the agent can call. They take structured input, do something, and return a result. The agent decides when to call them based on the conversation context. This is the primitive you’ll use most.

Use tools when the operation has side effects, when the input varies per invocation, or when the agent needs to decide whether and when to invoke it. Reading a file, querying a database, hitting an API, running a computation — these are all tools.

s.AddTool(
    mcp.NewTool("query_db",
        mcp.WithDescription("Run a read-only SQL query against the application database"),
        mcp.WithString("query",
            mcp.Required(),
            mcp.Description("SQL SELECT query to execute"),
        ),
    ),
    handleQueryDB,
)

Resources

Resources are data the agent can read. They’re identified by a URI and they return content. Unlike tools, resources don’t take arbitrary input — they’re more like GET endpoints. The agent (or the client application) requests them by URI.

Use resources when you want to expose static or semi-static data: configuration files, database schemas, documentation, reference material. The distinction from tools is that resources are pulled by the client rather than called by the agent, and they’re meant to provide context rather than perform actions.

s.AddResource(
    mcp.NewResource(
        "config://app/settings",
        "Application configuration",
        mcp.WithResourceDescription("Current application settings as JSON"),
        mcp.WithMIMEType("application/json"),
    ),
    handleGetConfig,
)

Resources can also be dynamic via templates. A resource template like db://tables/{table_name}/schema lets the client request the schema for any table by filling in the URI parameter.

Prompts

Prompts are pre-built message templates that the client can invoke. They’re useful for standardizing how an agent approaches a specific task — a code review prompt, a summarization prompt, a debugging prompt — without hardcoding those instructions into the client.

s.AddPrompt(
    mcp.NewPrompt("review_code",
        mcp.WithPromptDescription("Review code for bugs, style issues, and potential improvements"),
        mcp.WithArgument("language",
            mcp.ArgumentDescription("Programming language of the code"),
        ),
        mcp.WithArgument("code",
            mcp.RequiredArgument(),
            mcp.ArgumentDescription("The code to review"),
        ),
    ),
    handleCodeReview,
)

In practice, I use prompts the least of the three primitives. Most of what prompts do can be achieved by giving the agent a well-described tool and letting it figure out the invocation. Where prompts shine is when you want a reproducible, user-invocable workflow — the user clicks “Review Code” in the client UI and gets a consistent experience regardless of how they phrased the request.

When to use which

The decision is usually straightforward:

  • If the agent needs to do something (query, fetch, compute, write), it’s a tool.
  • If the agent needs to read something that exists at a known location, it’s a resource.
  • If you want to standardize a workflow that the user or agent can invoke by name, it’s a prompt.

Most MCP servers are 80-90% tools. That’s fine. Don’t force resources or prompts where a tool would be clearer.

Transports

MCP defines three transport mechanisms, and which one you use determines how the client talks to your server. This is the part where most tutorials stop at stdio and wave their hands about “you can also use HTTP.” Here’s the actual picture.

stdio

Stdio is the simplest transport. The client spawns your server as a subprocess. Your server reads JSON-RPC messages from stdin and writes responses to stdout. Stderr is yours for logging — the client ignores it.

if err := server.ServeStdio(s); err != nil {
    fmt.Fprintf(os.Stderr, "server error: %v\n", err)
    os.Exit(1)
}

This is the right choice for local tools that run alongside the agent. Claude Desktop, Claude Code, Cursor, and most MCP clients launch stdio servers as child processes. The lifecycle is simple: client starts you, client stops you. No ports, no networking, no auth. The server is effectively a function call with a process boundary.

All four of my daemons use stdio as the MCP transport layer, even though they’re long-running daemons internally. The daemon manages its own lifecycle (auto-spawn, warm cache, file watchers), and the stdio MCP interface is just a thin shell that translates JSON-RPC calls into daemon queries. The two concerns are separate and they should stay separate.

Use stdio when: the client and server are on the same machine, and the client manages the server’s lifecycle.

SSE (Server-Sent Events)

SSE was the original remote transport in the MCP spec. The server runs as an HTTP server with two endpoints: a GET /sse endpoint that opens a long-lived event stream for server-to-client messages, and a POST /message endpoint that the client uses to send requests. The event stream carries responses and notifications, the POST endpoint carries requests.

sseServer := server.NewSSEServer(s,
    server.WithBaseURL("http://localhost:8080"),
)

if err := sseServer.Start(":8080"); err != nil {
    log.Fatalf("sse server error: %v", err)
}

SSE works and it’s widely supported. The downsides are architectural: you need two endpoints, the event stream holds a connection open for the lifetime of the session, and load balancing SSE connections is annoying because they’re stateful. If you’re running behind a reverse proxy that doesn’t handle long-lived connections well, SSE will give you trouble.

Use SSE when: you need a remote server and your clients don’t support streamable HTTP yet. This is the legacy remote transport and new projects should prefer streamable HTTP.

Streamable HTTP

Streamable HTTP is the current recommended transport for remote MCP servers. It’s simpler than SSE: the client sends a POST request to a single endpoint, and the server responds with either a regular JSON response or an SSE stream, depending on whether the response is a single message or a sequence of messages.

httpServer := server.NewStreamableHTTPServer(s)

http.Handle("/mcp", httpServer)
if err := http.ListenAndServe(":8080", nil); err != nil {
    log.Fatalf("http server error: %v", err)
}

The advantage over SSE is that each request is a normal HTTP request-response cycle. No long-lived connections, no special load balancer configuration, no two-endpoint dance. When the server needs to stream (progress notifications, partial results), it can upgrade the response to SSE on the fly. When it doesn’t, it returns a plain JSON response.

Use streamable HTTP when: you’re deploying a remote MCP server that clients will reach over the network. This is the transport you want for production remote deployments.

Which transport for which situation

ScenarioTransportWhy
Local tool, Claude Desktop / Claude CodestdioClient manages lifecycle, no networking needed
Local tool, multiple clients on same machinestdioEach client spawns its own instance
Remote server, internal teamStreamable HTTPStandard HTTP, works behind any reverse proxy
Remote server, public APIStreamable HTTP + authAdd OAuth2 or API key middleware
Legacy clients that only support SSESSECompatibility, migrate when you can

In practice, most Go MCP servers should start with stdio and add streamable HTTP when they need remote access. The server logic is identical — you’re just changing how it’s wired up at the top level.

Production patterns

This is the section that doesn’t exist anywhere else, because it’s the gap between “it works in a demo” and “I trust this in a real codebase.” These patterns came out of building scry, tome, lore, and flume. None of them are MCP-specific ideas, they’re standard Go practices applied to MCP servers, but the application isn’t obvious if you haven’t done it before.

Structured error handling

MCP has two kinds of errors and it’s important not to confuse them. A transport error (malformed JSON-RPC, unknown method) is a protocol-level failure — the SDK handles these for you. A tool error (file not found, query failed, invalid input) is an application-level failure that you return as a tool result, not as a Go error.

func handleReadFile(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
    path, ok := req.Params.Arguments["path"].(string)
    if !ok || path == "" {
        // Application error: return it as a tool result so the agent can see it
        return mcp.NewToolResultError("path is required"), nil
    }

    data, err := os.ReadFile(path)
    if err != nil {
        // Application error: file doesn't exist, permission denied, etc.
        return mcp.NewToolResultError(fmt.Sprintf("failed to read file: %v", err)), nil
    }

    return mcp.NewToolResultText(string(data)), nil
}

The rule: return a Go error only when something is broken at the infrastructure level (database connection dead, out of memory, context cancelled). Return mcp.NewToolResultError() when the operation failed in a way the agent can understand and react to. If you return a Go error from a tool handler, the SDK turns it into a JSON-RPC error response and most agents won’t handle it gracefully. If you return a tool result error, the agent sees the message, understands what went wrong, and can try a different approach.

I’ve seen codebases that treat every err != nil as a Go error return. The agent gets a cryptic JSON-RPC error, retries the exact same call, gets the same error, and burns through its tool-call budget. Returning tool result errors with clear messages fixes this completely.

Context propagation and cancellation

Every tool handler receives a context.Context. Use it. If your tool calls a database, pass the context. If it makes an HTTP request, pass the context. If it runs a subprocess, pass the context. When the agent cancels a request (user hit stop, conversation moved on), the context cancels and your handler should stop doing work.

func handleQueryDB(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
    query, _ := req.Params.Arguments["query"].(string)

    rows, err := db.QueryContext(ctx, query)
    if err != nil {
        if ctx.Err() != nil {
            return mcp.NewToolResultError("query cancelled"), nil
        }
        return mcp.NewToolResultError(fmt.Sprintf("query failed: %v", err)), nil
    }
    defer rows.Close()

    // ... process rows
}

This matters more than you’d think. Agents make a lot of speculative tool calls, and if your handler ignores cancellation, you end up with zombie queries and orphaned subprocesses that consume resources long after the agent has moved on. In scry, every query checks ctx.Err() before doing any real work, because the daemon is handling concurrent requests from multiple agent sessions and a cancelled query that keeps running is pure waste.

Logging

Don’t write to stdout. Stdout is the transport channel for stdio servers — any stray fmt.Println will corrupt the JSON-RPC stream and crash the client. This is the most common mistake when people convert an existing CLI tool into an MCP server.

Stderr works for basic logging in stdio mode, but for anything structured, use log/slog or zerolog and point them at a file:

logFile, _ := os.OpenFile(
    filepath.Join(os.TempDir(), "my-mcp-server.log"),
    os.O_CREATE|os.O_WRONLY|os.O_APPEND,
    0644,
)

logger := slog.New(slog.NewJSONHandler(logFile, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))

slog.SetDefault(logger)

In scry I use zerolog writing to ~/.scry/scryd.log with daily rotation. Every log line carries the repo name, the query type, and the latency. When something goes wrong in production (and it will), structured logs with enough context to reproduce the problem are the difference between a ten-minute fix and a two-hour investigation.

For streamable HTTP servers, stdout is fine for logging since it’s not the transport channel. But I’d still recommend structured file logging — it’s a one-time setup and it works the same regardless of transport.

Graceful shutdown

For stdio servers, graceful shutdown is mostly handled by the SDK. When the client closes stdin, your server exits. But if your server manages stateful resources (database connections, file watchers, open stores), you want to clean those up rather than letting the OS rip them away.

func main() {
    s := server.NewMCPServer("my-server", "1.0.0",
        server.WithToolCapabilities(true),
    )

    // ... register tools

    ctx, cancel := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM,
    )
    defer cancel()

    go func() {
        <-ctx.Done()
        cleanup()
        os.Exit(0)
    }()

    if err := server.ServeStdio(s); err != nil {
        cleanup()
        fmt.Fprintf(os.Stderr, "server error: %v\n", err)
        os.Exit(1)
    }
}

For HTTP servers, use http.Server.Shutdown(ctx) with a deadline. Give in-flight requests a few seconds to finish, then close. The stdlib http.Server handles the draining for you.

Middleware and interceptors

The official SDK doesn’t have a formal middleware chain, but for streamable HTTP servers you get the full net/http middleware ecosystem. Auth, rate limiting, request logging, CORS — all standard http.Handler wrappers.

httpServer := server.NewStreamableHTTPServer(s)

handler := withAuth(
    withRequestLogging(
        withCORS(httpServer),
    ),
)

http.ListenAndServe(":8080", handler)

For stdio servers, middleware doesn’t apply in the same way because there’s no HTTP layer to wrap. If you need cross-cutting concerns on tool handlers (logging every call, validating inputs, rate limiting), write a wrapper function:

func withLogging(
    name string,
    handler func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error),
) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) {
    return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        start := time.Now()
        result, err := handler(ctx, req)
        slog.Info("tool call",
            "tool", name,
            "duration_ms", time.Since(start).Milliseconds(),
            "error", err != nil,
        )
        return result, err
    }
}

s.AddTool(readFileTool, withLogging("read_file", handleReadFile))

Nothing fancy. It’s just functions wrapping functions. The same pattern Go developers have been using since before middleware was a word.

Testing

This is the part I find most underserved in the MCP ecosystem. There’s almost nothing written about testing MCP servers, in Go or any other language. The approaches I’ve settled on are pretty standard Go testing patterns, adapted for the MCP request/response shape.

Unit testing tool handlers

Tool handlers are just functions with a specific signature. Test them like any other function:

func TestHandleReadFile(t *testing.T) {
    // Create a temp file with known content
    tmp := t.TempDir()
    path := filepath.Join(tmp, "test.txt")
    os.WriteFile(path, []byte("hello world"), 0644)

    tests := []struct {
        name    string
        args    map[string]interface{}
        wantErr bool
        want    string
    }{
        {
            name:    "valid file",
            args:    map[string]interface{}{"path": path},
            wantErr: false,
            want:    "hello world",
        },
        {
            name:    "missing path",
            args:    map[string]interface{}{},
            wantErr: true,
        },
        {
            name:    "nonexistent file",
            args:    map[string]interface{}{"path": "/does/not/exist"},
            wantErr: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := mcp.CallToolRequest{}
            req.Params.Arguments = tt.args

            result, err := handleReadFile(context.Background(), req)
            if err != nil {
                t.Fatalf("unexpected Go error: %v", err)
            }

            if tt.wantErr {
                if !result.IsError {
                    t.Fatal("expected tool error, got success")
                }
                return
            }

            if result.IsError {
                t.Fatalf("unexpected tool error: %v", result.Content)
            }

            text := result.Content[0].(mcp.TextContent).Text
            if text != tt.want {
                t.Errorf("got %q, want %q", text, tt.want)
            }
        })
    }
}

Table-driven tests. Parallel-safe. No external dependencies. The handler doesn’t know or care that it’s running inside a test instead of an MCP server. This is the main advantage of the SDK’s design — handlers are pure functions, not methods on a framework object.

Integration testing with an in-process client

For testing the full server (tool registration, capability negotiation, request routing), you can wire up a client and server in-process without any networking:

func TestServerIntegration(t *testing.T) {
    s := server.NewMCPServer("test-server", "1.0.0",
        server.WithToolCapabilities(true),
    )
    s.AddTool(readFileTool, handleReadFile)

    clientTransport, serverTransport := server.NewInMemoryTransport()
    go server.ServeTransport(s, serverTransport)

    client := client.New(clientTransport)
    ctx := context.Background()

    if err := client.Initialize(ctx); err != nil {
        t.Fatalf("initialize failed: %v", err)
    }

    // List tools
    tools, err := client.ListTools(ctx)
    if err != nil {
        t.Fatalf("list tools failed: %v", err)
    }
    if len(tools.Tools) != 1 {
        t.Fatalf("expected 1 tool, got %d", len(tools.Tools))
    }

    // Call a tool
    result, err := client.CallTool(ctx, "read_file", map[string]interface{}{
        "path": "/etc/hostname",
    })
    if err != nil {
        t.Fatalf("call tool failed: %v", err)
    }
    if result.IsError {
        t.Fatalf("tool returned error: %v", result.Content)
    }
}

This tests the full JSON-RPC flow: initialization, capability negotiation, tool discovery, and tool invocation. No subprocess, no port binding, no timing sensitivity. The in-memory transport is fast enough that you can run hundreds of these in a second.

Testing HTTP servers

For streamable HTTP servers, use httptest.NewServer:

func TestHTTPServer(t *testing.T) {
    s := server.NewMCPServer("test-server", "1.0.0",
        server.WithToolCapabilities(true),
    )
    s.AddTool(readFileTool, handleReadFile)

    httpServer := server.NewStreamableHTTPServer(s)
    ts := httptest.NewServer(httpServer)
    defer ts.Close()

    // Now you have a real HTTP server at ts.URL
    // Use the MCP client library pointed at ts.URL,
    // or make raw HTTP requests to test specific behaviors
}

Standard Go testing infrastructure, nothing MCP-specific about it. The stdlib test server handles port allocation, cleanup, and parallel test isolation.

Deployment

A Go MCP server is a static binary. No runtime, no interpreter, no dependency manager. This makes deployment straightforward in ways that Python and TypeScript MCP servers can’t match.

Local distribution: just build the binary

For stdio servers that users run locally, the deployment story is “give them a binary”:

GOOS=darwin GOARCH=arm64 go build -o my-server-darwin-arm64 .
GOOS=linux  GOARCH=amd64 go build -o my-server-linux-amd64 .

Users download the binary, make it executable, and point their MCP client at it. No npm install, no pip install, no virtual environment. This is why I write MCP servers in Go instead of Python — the person using the tool doesn’t need to have Go installed, doesn’t need to manage dependencies, and doesn’t need to debug version conflicts.

For automated releases, goreleaser handles cross-compilation, checksums, and GitHub release artifacts in a single config:

# .goreleaser.yml
builds:
  - binary: my-server
    goos: [darwin, linux, windows]
    goarch: [amd64, arm64]
    ldflags:
      - -s -w
      - -X main.version={{.Version}}

goreleaser release builds for every platform, generates checksums, and publishes to GitHub Releases. Users download the binary for their platform and they’re done.

Remote servers: Docker

For streamable HTTP servers deployed to a cloud platform, a multi-stage Docker build keeps the image small:

FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /server .

FROM alpine:3.19
RUN apk add --no-cache ca-certificates
COPY --from=build /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

The final image is typically 15-25 MB. Deploy it to Cloud Run, Fly.io, Railway, or any container platform. The server listens on a port, handles HTTP requests, and scales horizontally because streamable HTTP is stateless per request.

Claude Desktop config for remote servers

For remote MCP servers, Claude Desktop config points at a URL instead of a binary:

{
  "mcpServers": {
    "my-remote-server": {
      "url": "https://my-server.example.com/mcp",
      "headers": {
        "Authorization": "Bearer your-api-key"
      }
    }
  }
}

What I learned from shipping four of these

I’ll keep this short because the rest of the post is already long and I don’t want to end on a lecture.

Tool descriptions matter more than tool implementations. The agent decides whether to call your tool based entirely on the description string. I’ve seen tools that work perfectly but never get called because the description is vague. “Query the database” is worse than “Run a read-only SQL SELECT query against the application database and return results as JSON.” Be specific about what it does, what it expects, and what it returns.

Start with fewer tools. Scry started with three tools and now has around twelve. Each tool I added came from watching an agent try to use the existing tools and fail because it needed something slightly different. If I’d started with twelve tools, half of them would have been wrong because I didn’t yet understand how agents actually use code intelligence. Ship the minimum, watch the usage, and add tools in response to real behavior.

Stdio is fine for more things than you think. I defaulted to stdio for all four daemons because the servers run on the same machine as the agent. Even though scry is a long-lived daemon with file watchers and a warm cache, the MCP interface is still stdio. The daemon lifecycle is separate from the MCP lifecycle. Don’t reach for HTTP unless you actually need remote access.

The build-test cycle should be fast. If it takes more than a few seconds to rebuild and test your MCP server, you’ll stop iterating. Go’s compile times help. A go build && claude mcp remove my-server && claude mcp add my-server ./my-server cycle takes about two seconds. Keep it that way.

The MCP spec is the authoritative reference for everything covered here. The official Go SDK has working examples in its examples/ directory. All four of my MCP servers are on GitHub if you want to see what production code looks like versus tutorial code.

Back to all writing