// /ai-engineering/building-production-mcp-servers-in-go
Go gopher mascot with golden MCP connector symbol on dark background

Building Production MCP Servers in Go

The Model Context Protocol is everywhere. Claude Code, ChatGPT, VS Code Copilot, Cursor — they all speak MCP now. But if you search for "how to build an MCP server," you'll drown in Python and...

5 views

The Model Context Protocol is everywhere. Claude Code, ChatGPT, VS Code Copilot, Cursor — they all speak MCP now. But if you search for "how to build an MCP server," you'll drown in Python and TypeScript tutorials. Go developers get a README and a wave goodbye.

That changes today. This is a practical, opinionated guide to building production-grade MCP servers in Go using mcp-go — the community SDK that implements MCP spec 2025-11-25. No toy examples. Real patterns for real servers.

Why Go for MCP Servers

MCP servers are long-running processes that handle concurrent requests from LLM clients. This is exactly what Go was designed for. The case is straightforward:

Single binary deployment. No runtime, no virtualenv, no node_modules. Build once, copy to server, run. This matters when your MCP server ships alongside infrastructure tooling.

Goroutine-native concurrency. MCP servers handle multiple tool calls simultaneously. Go's goroutine model maps naturally to this — no async/await coloring, no callback chains.

Low memory footprint. A Go MCP server idles at 10-15MB. The equivalent Python server with FastMCP starts at 50MB+. When you're running multiple MCP servers per host, this adds up.

Type safety at the protocol boundary. MCP uses JSON-RPC 2.0 with typed schemas. Go's struct-based JSON handling catches schema mismatches at compile time that Python discovers at midnight in production.

The SDK: mcp-go

The dominant Go MCP library is mark3labs/mcp-go. It's not an official Anthropic SDK — there isn't one for Go (yet) — but it's the de facto standard with 482+ commits and active maintenance.

Install it:

go get github.com/mark3labs/mcp-go

The library provides three main packages:

  • mcp — protocol types, tool/resource/prompt builders
  • server — MCP server implementation with transport support
  • client — MCP client for testing or building hosts

Your First MCP Server in 30 Lines

Here's a complete, working MCP server that exposes a single tool:

package main
import (
    "context"
    "fmt"
    "log"
    "os/exec"
"github.com/mark3labs/mcp-go/mcp"
    "github.com/mark3labs/mcp-go/server"
)
func main() {
    s := server.NewMCPServer(
        "dns-lookup",
        "1.0.0",
        server.WithToolCapabilities(false),
    )
tool := mcp.NewTool("resolve",
        mcp.WithDescription("Resolve a domain name to IP addresses"),
        mcp.WithString("domain",
            mcp.Required(),
            mcp.Description("The domain to resolve"),
        ),
    )
s.AddTool(tool, handleResolve)
if err := server.ServeStdio(s); err != nil {
        log.Fatalf("server error: %v", err)
    }
}
func handleResolve(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
    domain, err := req.RequireString("domain")
    if err != nil {
        return mcp.NewToolResultError("domain is required"), nil
    }
out, err := exec.CommandContext(ctx, "dig", "+short", domain).Output()
    if err != nil {
        return mcp.NewToolResultError(fmt.Sprintf("lookup failed: %v", err)), nil
    }
return mcp.NewToolResultText(string(out)), nil
}

Build it, point Claude Code at it in your claude_code_config.json, and you have a DNS lookup tool available to your AI assistant.

A few things to notice:

  1. server.WithToolCapabilities(false) — the false means tool list changes aren't dynamic. Set to true if you add/remove tools at runtime.
  2. req.RequireString("domain") — type-safe parameter extraction. No interface{} casting.
  3. mcp.NewToolResultError() — returns a tool error to the LLM, not a protocol error. The LLM sees the message and can retry or inform the user.
  4. Context propagation — the ctx carries cancellation from the client. Always pass it through.

Defining Tool Schemas

MCP tools declare their input schemas using JSON Schema. The mcp-go builder API makes this ergonomic:

tool := mcp.NewTool("query_database",
    mcp.WithDescription("Execute a read-only SQL query against the analytics database"),
    mcp.WithString("query",
        mcp.Required(),
        mcp.Description("SQL SELECT statement"),
    ),
    mcp.WithString("database",
        mcp.Description("Target database name"),
        mcp.Enum("analytics", "reporting", "staging"),
    ),
    mcp.WithNumber("limit",
        mcp.Description("Maximum rows to return"),
    ),
    mcp.WithBoolean("explain",
        mcp.Description("Return EXPLAIN plan instead of results"),
    ),
)

Available schema types: WithString, WithNumber, WithBoolean, WithArray, WithObject. Each accepts constraint options like Required(), Description(), Enum(), and for arrays, WithStringItems() or WithNumberItems().

The schema is sent to the LLM during tool discovery. A well-written description and tight constraints directly improve how accurately the model calls your tool.

Transport: stdio vs. Streamable HTTP

MCP supports multiple transports. Your choice depends on deployment model.

stdio (Default)

if err := server.ServeStdio(s); err != nil {
    log.Fatal(err)
}

The host (Claude Code, VS Code, etc.) spawns your server as a child process and communicates over stdin/stdout. This is the most common transport for local tools.

When to use: CLI tools, development servers, anything running on the user's machine.

Streamable HTTP

httpServer := server.NewStreamableHTTPServer(s,
    server.WithBaseURL("/mcp"),
)
log.Fatal(http.ListenAndServe(":8080", httpServer))

The server runs as an HTTP service. Clients connect via POST requests with optional SSE streaming for server-initiated messages.

When to use: Shared infrastructure, multi-tenant servers, remote deployments where the MCP server runs on a different host than the LLM client.

SSE (Legacy)

SSE transport is still supported for backward compatibility but Streamable HTTP is the recommended approach for new HTTP-based servers.

Resources: Exposing Data

Tools perform actions. Resources expose data. The distinction matters — resources are meant to be read by the LLM for context, not to trigger side effects.

// Static resource with a fixed URI
resource := mcp.NewResource(
    "config://app/settings",
    "Application Settings",
    mcp.WithResourceDescription("Current application configuration"),
    mcp.WithMIMEType("application/json"),
)
s.AddResource(resource, func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
    cfg, err := loadConfig()
    if err != nil {
        return nil, fmt.Errorf("failed to load config: %w", err)
    }
data, _ := json.Marshal(cfg)
    return []mcp.ResourceContents{
        mcp.TextResourceContents{
            URI:      "config://app/settings",
            MIMEType: "application/json",
            Text:     string(data),
        },
    }, nil
})

For dynamic data with variable URIs, use resource templates:

template := mcp.NewResourceTemplate(
    "logs://{service}/{date}",
    "Service Logs",
    mcp.WithTemplateDescription("Fetch logs for a specific service and date"),
    mcp.WithTemplateMIMEType("text/plain"),
)
s.AddResourceTemplate(template, func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
    // Parse service and date from req.Params.URI
    // Fetch and return the logs
})

Production Patterns

Error Handling: Tool Errors vs. Protocol Errors

This is the most common mistake in MCP server implementations. There are two kinds of errors:

Tool errors — the operation failed, but the protocol is fine. Return these via mcp.NewToolResultError(). The LLM sees the error message and can react (retry, inform user, try a different approach).

func handleDeploy(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
    env, _ := req.RequireString("environment")
    if env == "production" && !isDeployWindowOpen() {
        return mcp.NewToolResultError("deploy window is closed — production deploys allowed Mon-Thu 10:00-16:00 UTC"), nil
    }
    // ...
}

Protocol errors — something is fundamentally wrong. Return these as the error return value. This signals a protocol-level failure, not a tool-level one.

func handleQuery(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
    db, err := getDBConnection()
    if err != nil {
        // This is infrastructure failure, not a tool result
        return nil, fmt.Errorf("database unavailable: %w", err)
    }
    // ...
}

Middleware and Hooks

mcp-go supports request hooks for cross-cutting concerns like logging, auth, and metrics:

s := server.NewMCPServer("my-server", "1.0.0",
    server.WithToolCapabilities(false),
)
// Add before/after hooks
s.AddRequestHook(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
    log.Printf("→ %s (id=%v)", method, id)
})
s.AddResponseHook(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) {
    if err != nil {
        log.Printf("← %s ERROR: %v", method, err)
    } else {
        log.Printf("← %s OK", method)
    }
})

For tool-specific middleware:

s.AddToolHandlerMiddleware(func(next server.ToolHandlerFunc) server.ToolHandlerFunc {
    return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        start := time.Now()
        result, err := next(ctx, req)
        duration := time.Since(start)
        metrics.RecordToolCall(req.Params.Name, duration, err)
        return result, err
    }
})

Recovery from Panics

Production servers must not crash on a single bad tool call:

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

WithRecovery() catches panics in tool handlers and converts them to error responses instead of crashing the process.

Graceful Shutdown

For HTTP-based servers, handle shutdown properly:

httpServer := server.NewStreamableHTTPServer(s)
srv := &http.Server{
    Addr:    ":8080",
    Handler: httpServer,
}
go func() {
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
    <-sigCh
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    srv.Shutdown(ctx)
}()
log.Fatal(srv.ListenAndServe())

Session-Aware Tool Filtering

Different clients might need different tool sets. mcp-go supports per-session tool filtering:

s.AddSessionToolFilter(func(ctx context.Context, session server.SessionInfo, tool mcp.Tool) bool {
    // Only expose admin tools to authenticated sessions
    if strings.HasPrefix(tool.Name, "admin_") {
        return session.HasRole("admin")
    }
    return true
})

Async Tools with Task Support

For long-running operations, MCP supports task-augmented tools. The client gets a task ID immediately and polls for results:

tool := mcp.NewTool("generate_report",
    mcp.WithDescription("Generate a comprehensive analytics report"),
    mcp.WithTaskSupport(mcp.TaskSupportRequired),
    mcp.WithString("period",
        mcp.Required(),
        mcp.Enum("daily", "weekly", "monthly"),
    ),
)
s.AddTaskTool(tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CreateTaskResult, error) {
    period, _ := req.RequireString("period")
// This runs asynchronously — the client polls for completion
    report, err := generateReport(ctx, period)
    if err != nil {
        return nil, err
    }
return &mcp.CreateTaskResult{
        Task: mcp.Task{
            // Task metadata
        },
    }, nil
})

Control concurrency with server.WithMaxConcurrentTasks(10) to prevent resource exhaustion.

Real-World Example: oCMS Blog MCP Server

Let's build something you can actually ship. oCMS is a Go-based CMS with a REST API. We'll create an MCP server that lets Claude Code manage blog posts on an oCMS-powered site — list posts, create drafts, manage tags, upload media. This turns your AI assistant into a content management tool.

The oCMS API follows a standard pattern: Bearer token auth, JSON request/response, RESTful endpoints at /api/v1/*. Here's the server:

package main
import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "mime/multipart"
    "net/http"
    "os"
    "path/filepath"
    "strconv"
"github.com/mark3labs/mcp-go/mcp"
    "github.com/mark3labs/mcp-go/server"
)
type OCMSClient struct {
    BaseURL string
    APIKey  string
    HTTP    *http.Client
}
func NewOCMSClient(baseURL, apiKey string) *OCMSClient {
    return &OCMSClient{
        BaseURL: baseURL,
        APIKey:  apiKey,
        HTTP:    &http.Client{},
    }
}
func (c *OCMSClient) doJSON(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
    var reqBody io.Reader
    if body != nil {
        data, err := json.Marshal(body)
        if err != nil {
            return nil, fmt.Errorf("marshal request: %w", err)
        }
        reqBody = bytes.NewReader(data)
    }
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reqBody)
    if err != nil {
        return nil, err
    }
    req.Header.Set("Authorization", "Bearer "+c.APIKey)
    if body != nil {
        req.Header.Set("Content-Type", "application/json")
    }
resp, err := c.HTTP.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
respData, _ := io.ReadAll(resp.Body)
    if resp.StatusCode >= 400 {
        return nil, fmt.Errorf("API %d: %s", resp.StatusCode, string(respData))
    }
    return respData, nil
}
func main() {
    baseURL := os.Getenv("OCMS_BASE_URL") // e.g. https://www.it-digest.info/api/v1
    apiKey := os.Getenv("OCMS_API_KEY")
    if baseURL == "" || apiKey == "" {
        log.Fatal("OCMS_BASE_URL and OCMS_API_KEY must be set")
    }
client := NewOCMSClient(baseURL, apiKey)
s := server.NewMCPServer("ocms-blog", "1.0.0",
        server.WithToolCapabilities(false),
        server.WithRecovery(),
    )
// --- Tools ---
s.AddTool(
        mcp.NewTool("list_posts",
            mcp.WithDescription("List blog posts with optional filtering by status, category, or tag"),
            mcp.WithString("status", mcp.Description("Filter: draft or published"), mcp.Enum("draft", "published")),
            mcp.WithNumber("category", mcp.Description("Filter by category ID")),
            mcp.WithNumber("per_page", mcp.Description("Results per page, max 100")),
        ),
        makeListPosts(client),
    )
s.AddTool(
        mcp.NewTool("get_post",
            mcp.WithDescription("Get a single post by ID or slug, including full body, categories, and tags"),
            mcp.WithString("identifier", mcp.Required(), mcp.Description("Post ID (numeric) or slug")),
        ),
        makeGetPost(client),
    )
s.AddTool(
        mcp.NewTool("create_post",
            mcp.WithDescription("Create a new blog post as a draft. Body should be HTML."),
            mcp.WithString("title", mcp.Required(), mcp.Description("Post title")),
            mcp.WithString("slug", mcp.Required(), mcp.Description("URL slug, must be unique")),
            mcp.WithString("body", mcp.Required(), mcp.Description("Post body in HTML")),
            mcp.WithString("meta_title", mcp.Description("SEO title, ~60 chars")),
            mcp.WithString("meta_description", mcp.Description("SEO description, ~160 chars")),
            mcp.WithArray("tags", mcp.WithStringItems(), mcp.Description("Tag names (auto-creates missing tags)")),
            mcp.WithArray("category_ids", mcp.WithNumberItems(), mcp.Description("Category IDs to assign")),
        ),
        makeCreatePost(client),
    )
s.AddTool(
        mcp.NewTool("update_post",
            mcp.WithDescription("Update an existing post. Only provided fields are changed."),
            mcp.WithNumber("id", mcp.Required(), mcp.Description("Post ID")),
            mcp.WithString("title", mcp.Description("New title")),
            mcp.WithString("body", mcp.Description("New body HTML")),
            mcp.WithString("status", mcp.Description("draft or published"), mcp.Enum("draft", "published")),
            mcp.WithNumber("featured_image_id", mcp.Description("Media ID for featured image")),
            mcp.WithNumber("og_image_id", mcp.Description("Media ID for OG image")),
        ),
        makeUpdatePost(client),
    )
s.AddTool(
        mcp.NewTool("list_tags",
            mcp.WithDescription("List all tags with their IDs and slugs"),
        ),
        makeListTags(client),
    )
s.AddTool(
        mcp.NewTool("list_categories",
            mcp.WithDescription("List all categories in tree structure"),
        ),
        makeListCategories(client),
    )
s.AddTool(
        mcp.NewTool("upload_media",
            mcp.WithDescription("Upload an image file to the media library"),
            mcp.WithString("file_path", mcp.Required(), mcp.Description("Local path to the image file")),
            mcp.WithString("alt", mcp.Description("Alt text for the image")),
            mcp.WithNumber("folder_id", mcp.Description("Folder ID (2 = posts)")),
        ),
        makeUploadMedia(client),
    )
// --- Resources ---
s.AddResource(
        mcp.NewResource(
            "ocms://categories",
            "Blog Categories",
            mcp.WithResourceDescription("Available blog categories with IDs"),
            mcp.WithMIMEType("application/json"),
        ),
        func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
            data, err := client.doJSON(ctx, "GET", "/categories", nil)
            if err != nil {
                return nil, err
            }
            return []mcp.ResourceContents{
                mcp.TextResourceContents{
                    URI:      "ocms://categories",
                    MIMEType: "application/json",
                    Text:     string(data),
                },
            }, nil
        },
    )
if err := server.ServeStdio(s); err != nil {
        log.Fatal(err)
    }
}
// --- Tool Handlers ---
func makeListPosts(c *OCMSClient) server.ToolHandlerFunc {
    return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        path := "/pages?page_type=post&include=categories,tags"
if status, err := req.RequireString("status"); err == nil {
            path += "&status=" + status
        }
        if cat := req.GetFloat("category", 0); cat > 0 {
            path += "&category=" + strconv.Itoa(int(cat))
        }
        if pp := req.GetFloat("per_page", 20); pp != 20 {
            path += "&per_page=" + strconv.Itoa(int(pp))
        }
data, err := c.doJSON(ctx, "GET", path, nil)
        if err != nil {
            return mcp.NewToolResultError(fmt.Sprintf("failed to list posts: %v", err)), nil
        }
        return mcp.NewToolResultText(string(data)), nil
    }
}
func makeGetPost(c *OCMSClient) server.ToolHandlerFunc {
    return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        id, _ := req.RequireString("identifier")
// Try numeric ID first, fall back to slug
        path := "/pages/" + id + "?include=categories,tags"
        if _, err := strconv.Atoi(id); err != nil {
            path = "/pages/slug/" + id + "?include=categories,tags"
        }
data, err := c.doJSON(ctx, "GET", path, nil)
        if err != nil {
            return mcp.NewToolResultError(fmt.Sprintf("post not found: %v", err)), nil
        }
        return mcp.NewToolResultText(string(data)), nil
    }
}
func makeCreatePost(c *OCMSClient) server.ToolHandlerFunc {
    return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        title, _ := req.RequireString("title")
        slug, _ := req.RequireString("slug")
        body, _ := req.RequireString("body")
post := map[string]any{
            "title":     title,
            "slug":      slug,
            "body":      body,
            "status":    "draft",
            "page_type": "post",
        }
if mt, err := req.RequireString("meta_title"); err == nil {
            post["meta_title"] = mt
        }
        if md, err := req.RequireString("meta_description"); err == nil {
            post["meta_description"] = md
        }
        if tags := req.GetStringSlice("tags", nil); len(tags) > 0 {
            post["tags"] = tags
        }
        // category_ids would need similar handling
data, err := c.doJSON(ctx, "POST", "/pages", post)
        if err != nil {
            return mcp.NewToolResultError(fmt.Sprintf("failed to create post: %v", err)), nil
        }
        return mcp.NewToolResultText(string(data)), nil
    }
}
func makeUpdatePost(c *OCMSClient) server.ToolHandlerFunc {
    return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        id := int(req.GetFloat("id", 0))
        if id == 0 {
            return mcp.NewToolResultError("id is required"), nil
        }
update := map[string]any{}
        if t, err := req.RequireString("title"); err == nil {
            update["title"] = t
        }
        if b, err := req.RequireString("body"); err == nil {
            update["body"] = b
        }
        if s, err := req.RequireString("status"); err == nil {
            update["status"] = s
        }
        if img := req.GetFloat("featured_image_id", 0); img > 0 {
            update["featured_image_id"] = int(img)
        }
        if og := req.GetFloat("og_image_id", 0); og > 0 {
            update["og_image_id"] = int(og)
        }
data, err := c.doJSON(ctx, "PUT", fmt.Sprintf("/pages/%d", id), update)
        if err != nil {
            return mcp.NewToolResultError(fmt.Sprintf("failed to update post: %v", err)), nil
        }
        return mcp.NewToolResultText(string(data)), nil
    }
}
func makeListTags(c *OCMSClient) server.ToolHandlerFunc {
    return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        data, err := c.doJSON(ctx, "GET", "/tags?per_page=100", nil)
        if err != nil {
            return mcp.NewToolResultError(fmt.Sprintf("failed to list tags: %v", err)), nil
        }
        return mcp.NewToolResultText(string(data)), nil
    }
}
func makeListCategories(c *OCMSClient) server.ToolHandlerFunc {
    return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        data, err := c.doJSON(ctx, "GET", "/categories", nil)
        if err != nil {
            return mcp.NewToolResultError(fmt.Sprintf("failed to list categories: %v", err)), nil
        }
        return mcp.NewToolResultText(string(data)), nil
    }
}
func makeUploadMedia(c *OCMSClient) server.ToolHandlerFunc {
    return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        filePath, _ := req.RequireString("file_path")
        alt, _ := req.RequireString("alt")
        folderID := int(req.GetFloat("folder_id", 0))
file, err := os.Open(filePath)
        if err != nil {
            return mcp.NewToolResultError(fmt.Sprintf("cannot open file: %v", err)), nil
        }
        defer file.Close()
var buf bytes.Buffer
        w := multipart.NewWriter(&buf)
        part, _ := w.CreateFormFile("file", filepath.Base(filePath))
        io.Copy(part, file)
        if folderID > 0 {
            w.WriteField("folder_id", strconv.Itoa(folderID))
        }
        w.Close()
httpReq, _ := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/media", &buf)
        httpReq.Header.Set("Authorization", "Bearer "+c.APIKey)
        httpReq.Header.Set("Content-Type", w.FormDataContentType())
resp, err := c.HTTP.Do(httpReq)
        if err != nil {
            return mcp.NewToolResultError(fmt.Sprintf("upload failed: %v", err)), nil
        }
        defer resp.Body.Close()
respData, _ := io.ReadAll(resp.Body)
        if resp.StatusCode >= 400 {
            return mcp.NewToolResultError(fmt.Sprintf("upload error %d: %s", resp.StatusCode, string(respData))), nil
        }
// Set alt text if provided
        if alt != "" {
            var media struct {
                Data struct {
                    ID int `json:"id"`
                } `json:"data"`
            }
            json.Unmarshal(respData, &media)
            if media.Data.ID > 0 {
                c.doJSON(ctx, "PUT", fmt.Sprintf("/media/%d", media.Data.ID), map[string]string{"alt": alt})
            }
        }
return mcp.NewToolResultText(string(respData)), nil
    }
}

This is a complete, working MCP server for a real CMS. Claude Code can now list your posts, create drafts with tags and categories, upload featured images, and publish — all through natural language. The closure pattern (makeListPosts, makeCreatePost, etc.) keeps the HTTP client injected cleanly without globals.

The key design decisions here: every tool returns NewToolResultError for API failures (not Go errors), the doJSON helper centralizes auth and error handling, and the upload_media tool handles the multipart dance that every CMS API requires. The resource endpoint for categories gives the LLM context about available categories without needing a tool call.

Testing MCP Servers

mcp-go includes an in-process client for testing without spawning a subprocess:

func TestResolveTool(t *testing.T) {
    s := server.NewMCPServer("test", "1.0.0",
        server.WithToolCapabilities(false),
    )
    s.AddTool(resolveTool, handleResolve)
// Create an in-process client
    c := client.NewInProcessClient(s)
    if err := c.Start(context.Background()); err != nil {
        t.Fatal(err)
    }
    defer c.Close()
// Initialize the MCP session
    c.Initialize(context.Background(), nil)
// Call the tool
    result, err := c.CallTool(context.Background(), "resolve", map[string]any{
        "domain": "example.com",
    })
    if err != nil {
        t.Fatal(err)
    }
// Assert on result
    if result.IsError {
        t.Fatalf("unexpected error: %v", result.Content)
    }
}

No network, no subprocess, deterministic tests. This is one of Go's strengths — the test binary runs everything in-process.

Configuring for Claude Code

To use your MCP server with Claude Code, add it to .claude/claude_code_config.json:

{
  "mcpServers": {
    "ocms-blog": {
      "command": "/usr/local/bin/ocms-blog-mcp",
      "args": [],
      "env": {
        "OCMS_BASE_URL": "https://www.it-digest.info/api/v1",
        "OCMS_API_KEY": "your-api-key-here"
      }
    }
  }
}

For HTTP-based servers:

{
  "mcpServers": {
    "ocms-blog": {
      "url": "http://localhost:8080/mcp"
    }
  }
}

Deployment Checklist

Before shipping your MCP server to production:

  1. Enable recoveryserver.WithRecovery() prevents single-tool panics from killing the server.
  2. Add request logging — Use hooks to log every tool call. You'll need this for debugging LLM interactions.
  3. Set timeouts — Always pass context with deadlines to external calls. LLM clients will cancel requests they consider stale.
  4. Limit concurrency — Use WithMaxConcurrentTasks() for async tools. Use semaphores or worker pools for sync tools that hit external services.
  5. Return tool errors, not protocol errors — Most failures should be NewToolResultError(). Reserve Go errors for infrastructure failures.
  6. Write tool descriptions for the LLM — The description string is what the model reads to decide when and how to call your tool. Be specific, include constraints, mention what the tool does NOT do.
  7. Build static binariesCGO_ENABLED=0 go build gives you a binary that runs anywhere. Ship it in a scratch or distroless Docker image.

What's Next for Go and MCP

The MCP specification is evolving fast. The 2025-11-25 spec added task support, elicitation (servers asking users for input), and streamable HTTP transport. mcp-go tracks these changes closely, usually within weeks of spec releases.

There's no official Go SDK from Anthropic yet — the official SDKs cover TypeScript, Python, Java, Kotlin, and C#. But mcp-go fills the gap well, and the Go community's MCP adoption is accelerating. Several production MCP servers in the ecosystem (filesystem, GitHub, Kubernetes tools) are already written in Go.

If you're a Go developer building AI tooling, there's never been a better time to start. The protocol is stable, the SDK is mature, and LLM clients are hungry for tools.


Resources:
- mcp-go on GitHub
- MCP Specification (2025-11-25)
- MCP Official Documentation
- Claude Code MCP Configuration
- MCP Server Examples in Go

/**
* @author

OIV

* Fear not the AI that passes the test. Fear the one that pretends to fail it.

IT-Digest AI Assistant