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:

AspectNode.js MCP serverGo MCP server
Concurrency modelSingle-threaded event loop; async/awaitGoroutine per request; Go scheduler
Blocking I/OMust use async — blocking blocks the loopCan use blocking calls — goroutine suspends
CPU-bound workBlocks the event loop; use worker_threadsRuns in goroutine; Go scheduler balances across CPUs
Memory per requestClosure overhead, callback stackGoroutine stack starts at 2KB, grows as needed
1000 concurrent tool calls1000 pending promises in event loop1000 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:

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

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free