Guide · Go SDK
MCP server with Go
Go is an increasingly popular choice for MCP servers: fast startup, low memory footprint, goroutine concurrency that handles many concurrent tool calls without a thread pool, and static binaries that deploy anywhere without a runtime. The mark3labs/mcp-go package is the most widely used community SDK; an official Anthropic Go SDK is also in active development. This guide covers building a production MCP server in Go — tool registration, concurrency patterns, context propagation, testing, and deployment.
TL;DR
Use github.com/mark3labs/mcp-go to build Go MCP servers. Define tools as structs with JSON struct tags for schema generation, implement handlers as functions with context.Context as the first argument, and run with server.ServeHTTP() on net/http. Each tool call runs in its own goroutine — Go's scheduler handles concurrency without a thread pool. Deploy as a static binary in a FROM scratch Docker container (under 20MB). Monitor with AliveMCP — Go's fast startup and low memory make it ideal for always-on monitoring with tight timeout thresholds.
Project setup and SDK installation
Create a new Go module and install the MCP SDK and its dependencies:
mkdir mcp-server && cd mcp-server
go mod init github.com/yourorg/mcp-server
# The most widely used community SDK for Go MCP servers
go get github.com/mark3labs/mcp-go@latest
# JSON schema generation from Go structs
go get github.com/invopop/jsonschema@latest
// main.go — minimal MCP server in Go
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func main() {
s := server.NewMCPServer(
"my-mcp-server",
"1.0.0",
server.WithToolCapabilities(true),
server.WithRecovery(), // recover from panics in tool handlers
)
// Register tools (see next section)
registerTools(s)
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
slog.Info("Starting MCP server", "port", port)
httpServer := server.NewStreamableHTTPServer(s,
server.WithStateless(true), // stateless mode: no session tracking
)
if err := http.ListenAndServe(":"+port, httpServer); err != nil {
slog.Error("Server failed", "err", err)
os.Exit(1)
}
}
Registering tools with struct-based schemas
The mcp-go SDK lets you define tool input schemas as Go structs — the SDK generates the JSON schema automatically from struct tags. This is the idiomatic Go pattern vs JavaScript's Zod definitions:
// Tool input struct with JSON schema tags
type SearchArgs struct {
Query string `json:"query" jsonschema:"description=Search query string,minLength=1"`
Limit int `json:"limit" jsonschema:"description=Maximum results to return,minimum=1,maximum=100,default=10"`
Filter string `json:"filter" jsonschema:"description=Optional category filter,enum=all,enum=docs,enum=api"`
}
func registerTools(s *server.MCPServer) {
// Generate schema from struct (reflection-based)
r := new(jsonschema.Reflector)
r.DoNotReference = true
searchSchema := r.Reflect(SearchArgs{})
s.AddTool(
mcp.NewTool("search_docs",
mcp.WithDescription("Search the documentation index"),
mcp.WithSchema(searchSchema.Definitions["SearchArgs"]),
),
handleSearch,
)
// Simpler tools can use the fluent builder API without a struct
s.AddTool(
mcp.NewTool("get_status",
mcp.WithDescription("Get the current server status"),
),
handleStatus,
)
}
// Tool handler — signature is always (context.Context, mcp.CallToolRequest) (mcp.CallToolResult, error)
func handleSearch(ctx context.Context, req mcp.CallToolRequest) (mcp.CallToolResult, error) {
// Unmarshal arguments into the struct (type-safe)
var args SearchArgs
if err := req.UnmarshalArguments(&args); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid arguments: %v", err)), nil
}
if args.Limit == 0 {
args.Limit = 10 // apply default
}
// Context propagation: use ctx for database queries, HTTP calls, cancellation
results, err := searchDocs(ctx, args.Query, args.Limit, args.Filter)
if err != nil {
// Return MCP error (isError: true), not a Go error
// Go errors from handlers are internal failures; MCP errors are business logic failures
return mcp.NewToolResultError(fmt.Sprintf("search failed: %v", err)), nil
}
data, _ := json.Marshal(results)
return mcp.NewToolResultText(string(data)), nil
}
func handleStatus(ctx context.Context, req mcp.CallToolRequest) (mcp.CallToolResult, error) {
return mcp.NewToolResultText(`{"status":"ok","version":"1.0.0"}`), nil
}
The handler signature returns (mcp.CallToolResult, error). Return a mcp.NewToolResultError() for expected errors the LLM should see (tool not found, invalid input, API error). Return a Go error only for unexpected internal failures — the SDK wraps these in a 500 response. Use server.WithRecovery() to ensure panics are also caught and converted to 500s rather than crashing the server.
Goroutine concurrency vs Node.js event loop
The most important difference between Go and Node.js MCP servers is how they handle concurrent tool calls:
| Aspect | Node.js MCP server | Go MCP server |
|---|---|---|
| Concurrency model | Single-threaded event loop; async/await | Goroutine per request; Go scheduler |
| Blocking I/O | Must use async — blocking blocks the loop | Can use blocking calls — goroutine suspends |
| CPU-bound work | Blocks the event loop; use worker_threads | Runs in goroutine; Go scheduler balances across CPUs |
| Memory per request | Closure overhead, callback stack | Goroutine stack starts at 2KB, grows as needed |
| 1000 concurrent tool calls | 1000 pending promises in event loop | 1000 goroutines (lightweight — ~2KB each) |
In practice: Go MCP servers handle CPU-bound tools (JSON parsing, hashing, computation) better than Node.js because goroutines run on multiple OS threads. Node.js MCP servers handle I/O-bound tools (API calls, database queries) equally well because the event loop is efficient for I/O waits. Choose Go when you expect high concurrency or CPU-bound tool handlers.
// Go handles blocking I/O naturally — no async/await required
func handleDbQuery(ctx context.Context, req mcp.CallToolRequest) (mcp.CallToolResult, error) {
var args struct {
UserID string `json:"user_id"`
}
req.UnmarshalArguments(&args)
// This blocks the goroutine (not the entire server) while waiting for DB
// Other goroutines continue running concurrently
row := db.QueryRowContext(ctx, "SELECT name, email FROM users WHERE id = $1", args.UserID)
var name, email string
if err := row.Scan(&name, &email); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("user %s not found", args.UserID)), nil
}
return mcp.NewToolResultText(fmt.Sprintf(`{"name":%q,"email":%q}`, name, email)), nil
}
Context propagation and cancellation
Go's context.Context is the mechanism for cancellation and deadline propagation. Pass the context from the tool handler into every downstream call — database queries, HTTP requests, external API calls. When the MCP client disconnects mid-call, the context is cancelled and your downstream calls abort cleanly:
func handleLongOperation(ctx context.Context, req mcp.CallToolRequest) (mcp.CallToolResult, error) {
var args struct {
DataID string `json:"data_id"`
}
req.UnmarshalArguments(&args)
// All downstream calls receive the context — they abort if client disconnects
data, err := fetchFromS3(ctx, args.DataID) // HTTP call with ctx
if err != nil {
if ctx.Err() != nil {
return mcp.NewToolResultError("operation cancelled by client"), nil
}
return mcp.NewToolResultError(fmt.Sprintf("fetch failed: %v", err)), nil
}
processed, err := processData(ctx, data) // CPU work checks ctx.Done()
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("processing failed: %v", err)), nil
}
if err := storeResult(ctx, args.DataID, processed); // DB write with ctx
err != nil {
return mcp.NewToolResultError(fmt.Sprintf("storage failed: %v", err)), nil
}
return mcp.NewToolResultText(fmt.Sprintf(`{"stored":true,"id":%q}`, args.DataID)), nil
}
// In CPU-bound work, check ctx.Done() periodically for cancellation
func processData(ctx context.Context, data []byte) ([]byte, error) {
result := make([]byte, 0, len(data))
for i, chunk := range chunks(data, 4096) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
result = append(result, transform(chunk)...)
_ = i
}
return result, nil
}
Testing Go MCP tools
Go's built-in testing package and table-driven test style map naturally to MCP tool testing. Test tool handlers directly as functions — no HTTP server needed for unit tests:
// server_test.go
package main
import (
"context"
"encoding/json"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
func TestHandleSearch(t *testing.T) {
// Table-driven tests: each row is a (input, expected output) pair
tests := []struct {
name string
args SearchArgs
wantErr bool
wantLen int
}{
{name: "basic query", args: SearchArgs{Query: "authentication", Limit: 5}, wantLen: 5},
{name: "empty query", args: SearchArgs{Query: ""}, wantErr: true},
{name: "limit capped at 100", args: SearchArgs{Query: "API", Limit: 999}, wantLen: 100},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
argBytes, _ := json.Marshal(tt.args)
req := mcp.CallToolRequest{}
json.Unmarshal(argBytes, &req.Params.Arguments)
result, err := handleSearch(context.Background(), req)
if err != nil {
t.Fatalf("unexpected Go error: %v", err)
}
if tt.wantErr {
if !result.IsError {
t.Errorf("expected MCP error, got success: %v", result.Content)
}
return
}
if result.IsError {
t.Errorf("unexpected MCP error: %v", result.Content)
}
var results []map[string]any
json.Unmarshal([]byte(result.Content[0].(mcp.TextContent).Text), &results)
if len(results) != tt.wantLen {
t.Errorf("got %d results, want %d", len(results), tt.wantLen)
}
})
}
}
// Integration test: full HTTP round-trip with a real MCP client
func TestMCPProtocol(t *testing.T) {
s := server.NewMCPServer("test", "1.0.0")
registerTools(s)
ts := httptest.NewServer(server.NewStreamableHTTPServer(s, server.WithStateless(true)))
defer ts.Close()
// Send a real initialize request and verify protocolVersion
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"test","version":"1.0"}}}`
resp, err := http.Post(ts.URL, "application/json", strings.NewReader(body))
if err != nil || resp.StatusCode != 200 {
t.Fatalf("initialize failed: status=%d err=%v", resp.StatusCode, err)
}
// Parse and verify protocolVersion in response
}
Run tests with go test ./... -race to catch data races in concurrent tool handlers. Go's race detector is particularly valuable for MCP servers where multiple tool calls run simultaneously as goroutines.
Docker deployment and static binary advantages
Go compiles to a static binary — no runtime required. This enables very small Docker images using FROM scratch or FROM gcr.io/distroless/static:
# Dockerfile — multi-stage Go MCP server build
FROM golang:1.23-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download # cache layer: only re-downloads on go.mod changes
COPY . .
# CGO_ENABLED=0: pure Go binary (no C library dependencies)
# -ldflags="-w -s": strip debug info (reduces binary size ~30%)
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o mcp-server ./cmd/server
# Final image: just the binary, no Go toolchain, no shell
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /build/mcp-server /mcp-server
EXPOSE 3000
ENTRYPOINT ["/mcp-server"]
The resulting image is typically 15–25MB (vs 200–400MB for Node.js Alpine images with node_modules). The binary starts in under 50ms. This makes Go MCP servers ideal for containerized environments where image pull time and startup latency matter.
# Deploy to Fly.io (runs globally in 35 regions)
fly launch --name my-mcp-server --dockerfile Dockerfile
fly secrets set DATABASE_URL=postgres://...
fly scale count 2 --region iad,fra # 2 instances: US East + Europe
# Health check and verify MCP protocol after deploy
curl -X POST https://my-mcp-server.fly.dev/ \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"check","version":"1.0"}}}' | jq .result.serverInfo
Monitoring Go MCP servers with AliveMCP
Go MCP servers have excellent monitoring properties: fast startup (no "warming up" period), predictable memory usage (no GC heap fluctuation), and goroutine dumps on request (runtime/pprof). The key things to monitor externally:
- Goroutine leak — a common Go bug where goroutines are started but never terminated (e.g., a goroutine blocked waiting on a channel that's never sent to). Goroutine count grows unboundedly. Detect with
/debug/pprof/goroutineendpoint. External AliveMCP monitoring catches the symptom (slow responses → timeout) before you diagnose the cause. - Database connection pool exhaustion — if all connections in
sql.DBare in use, new queries block.db.Stats().WaitCountshows blocked queries. AliveMCP detects this as increased tool call latency or timeout before your metrics alert. - Panic recovery —
server.WithRecovery()catches panics in handlers, but panics in non-handler goroutines (started by a tool handler withgo func()) crash the process. AliveMCP's external probe detects the restart as a brief outage and alerts immediately.
// Add a /metrics endpoint for Prometheus alongside the MCP server
mux := http.NewServeMux()
mux.Handle("/", mcpHandler) // MCP endpoint
mux.Handle("/metrics", promhttp.Handler()) // Prometheus metrics
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
// Verify DB connection before reporting healthy
if err := db.PingContext(r.Context()); err != nil {
http.Error(w, "db unreachable", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
http.ListenAndServe(":"+port, mux)
Add the Go MCP server URL to AliveMCP for continuous protocol-level monitoring. AliveMCP's initialize probe verifies the full MCP handshake — catching issues that /healthz misses, like a tools/list that returns an empty array because a registration function panicked at startup.
Frequently asked questions
Is there an official Anthropic Go SDK for MCP, or should I use mark3labs/mcp-go?
As of mid-2026, mark3labs/mcp-go is the most production-ready Go MCP SDK with active maintenance, comprehensive transport support, and broad community adoption. Anthropic has an official Go SDK in active development (check the modelcontextprotocol GitHub organization for the latest status). For new projects, mark3labs/mcp-go is the practical choice. When the official SDK stabilizes, migration should be straightforward — the core concepts (tool registration, handler function signature, transport abstraction) are similar across implementations.
How does error handling differ between Go and TypeScript MCP handlers?
In TypeScript, you typically throw an Error and the SDK catches it. In Go, you have two options: return a mcp.NewToolResultError("message") for expected errors the LLM should receive (this sets isError:true in the MCP response), or return a Go error as the second return value for unexpected internal failures (the SDK converts this to a 500 response). The distinction matters: returning a Go error means the tool call fails at the transport level (the LLM sees a failed call, not an error message). Returning a MCP tool error means the call succeeds at the protocol level (the LLM receives the error text and can decide what to do with it). As a rule: use mcp.NewToolResultError() for everything the LLM should handle; use Go errors only for catastrophic failures like out-of-memory or nil pointer panics that should never happen.
Can I use the stdio transport in Go for local MCP tools?
Yes. server.NewStdioServer(s) creates a stdio-based MCP server that reads JSON-RPC from stdin and writes to stdout — the standard pattern for Claude Desktop integrations. The same tool handlers work for both stdio and HTTP transports; only the server initialization differs. For production monitoring, switch to HTTP: AliveMCP can only probe HTTP endpoints, not stdio processes.
How do I share expensive resources (DB pool, HTTP client) across tool handlers?
Package-level variables or dependency injection via a struct receiver. Package-level is simplest: var db *sql.DB initialized in main(), used in handlers. This is safe in Go because *sql.DB is goroutine-safe. For more complex dependencies, create a Server struct that holds your dependencies, and implement tool handler functions as methods: func (s *Server) handleSearch(ctx context.Context, req mcp.CallToolRequest) (mcp.CallToolResult, error). Pass s.handleSearch to AddTool(). This is the idiomatic Go pattern for dependency injection without a container.
Further reading
- MCP SDK comparison — TypeScript vs Python vs Go: transport support and deployment
- MCP server multi-cloud deployment — AWS Lambda, GCP Cloud Run, Azure, Fly.io
- MCP server integration testing — real client, real server, no mocks
- MCP server zero-downtime deployment — rolling restarts and health checks
- MCP server health checks — protocol probes and readiness verification
- AliveMCP — continuous protocol monitoring for MCP servers