Skip to main content
The Lacanians
Tutorial9 min read

Building Custom MCP Servers in Go: A Practical Guide

Learn how to build production-ready MCP servers in Go. From project setup to tool registration, resource handling, and deployment patterns.

A

Abdul Hamid Achik

·Updated

Why Go for MCP Servers

We have written about the MCP revolution before: how the Model Context Protocol gives AI models structured access to your tools and services through a standard interface. Our early MCP servers were built in TypeScript, and that remains a great choice for many use cases.

But as we built more ambitious tools – vecgrep for semantic code search, tinyvault for secret management, noted for knowledge bases – we kept reaching for Go. The reasons are practical, not ideological.

Single binary distribution. An MCP server written in Go compiles to one binary. No runtime dependencies, no node_modules, no version conflicts. Users download the binary and point their AI client at it. That is the entire setup process.

Low resource overhead. MCP servers are long-running processes, and a small Go binary can keep its idle footprint modest. Measure your own workload and dependencies, but avoiding a separate language runtime can matter when several local servers run beside an editor and AI client.

Concurrency built in. MCP servers frequently need to perform I/O – reading files, querying databases, calling APIs. Go’s goroutines and channels handle concurrent tool calls naturally, without callback chains or async/await gymnastics.

Fast startup. MCP clients often launch local servers on demand. A compiled Go binary has little runtime initialization, which makes it a good fit for tools expected to become ready promptly.

If you are building an MCP server that will be distributed as a standalone tool, Go is hard to beat.

Project Setup

Start with a standard Go module. This guide uses mcp-go, a community implementation for building MCP servers in Go.

mkdir my-mcp-server && cd my-mcp-server
go mod init github.com/yourorg/my-mcp-server
go get github.com/mark3labs/mcp-go

Here is the minimal project structure we use for our MCP servers:

my-mcp-server/
├── main.go           # Entry point, server setup
├── tools/            # Tool implementations
│   ├── search.go
│   └── analyze.go
├── resources/        # Resource providers
│   └── config.go
└── internal/         # Shared utilities
    └── format.go

The Minimal Server

Every MCP server starts the same way: create a server instance, register capabilities, and connect to a transport. Here is the smallest functional server:

package main

import (
	"context"
	"fmt"
	"os"
	"os/exec"

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

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

	registerTools(s)
	registerResources(s)

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

That is the entire skeleton. The ServeStdio function handles the MCP protocol over stdin/stdout, which is the standard transport for local MCP servers. The AI client launches your binary as a subprocess and communicates through these streams.

Registering Tools

Tools are the primary way an AI model interacts with your server. Each tool has a name, a description, a JSON Schema for its input, and a handler function.

func registerTools(s *server.MCPServer) {
	searchTool := mcp.NewTool("search_codebase",
		mcp.WithDescription("Search the codebase for files matching a pattern or content query"),
		mcp.WithString("query",
			mcp.Required(),
			mcp.Description("Search query: a file pattern or content to find"),
		),
		mcp.WithString("scope",
			mcp.Description("Directory scope to limit the search"),
		),
	)

	s.AddTool(searchTool, handleSearch)
}

The handler function receives the parsed arguments and returns structured content. This is where your actual logic lives:

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

	if query == "" {
		return mcp.NewToolResultError("query parameter is required"), nil
	}

	if scope == "" {
		scope = "."
	}

	// Use ripgrep for fast content search
	cmd := exec.CommandContext(ctx, "rg",
		"--json", "--max-count", "5", "-e", query, "--", scope,
	)
	output, err := cmd.Output()
	if err != nil {
		if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
			return mcp.NewToolResultText(
				fmt.Sprintf("No results found for query: %s", query),
			), nil
		}

		return nil, fmt.Errorf("search failed: %w", err)
	}

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

Notice the error handling pattern. We do not return Go errors for “no results found” – that is a normal outcome, not a failure. We return a ToolResult with a helpful message so the AI model can adjust its approach. Reserve actual errors for situations where the tool genuinely cannot function.

Design Principles for Tools

After building the MCP tools we ship as part of our AI development services, we have learned a few things:

Keep tools focused. A tool called search_codebase that takes a query string is better than a generic execute tool that takes a command. The AI model reads the tool name and description to decide when to use it. Specific names lead to better tool selection.

Return structured data. JSON is almost always the right format. The AI model can parse structured data and present it however is appropriate for the conversation. Avoid formatting results for human consumption inside the tool.

Use the context. The context.Context parameter carries deadlines and cancellation signals from the MCP client. Respect them. Long-running operations should check ctx.Done() periodically.

Adding Resources

Resources give the AI model read access to data without requiring a tool call. They are ideal for configuration, documentation, and reference material that the model might need during a conversation.

func registerResources(s *server.MCPServer) {
	s.AddResource(
		mcp.NewResource(
			"config://project",
			"Project Configuration",
			mcp.WithResourceDescription("Current project settings and metadata"),
			mcp.WithMIMEType("application/json"),
		),
		handleProjectConfig,
	)
}

func handleProjectConfig(
	ctx context.Context,
	req mcp.ReadResourceRequest,
) ([]mcp.ResourceContents, error) {
	config, err := os.ReadFile("project.json")
	if err != nil {
		return nil, fmt.Errorf("failed to read project config: %w", err)
	}

	return []mcp.ResourceContents{
		mcp.TextResourceContents{
			URI:      "config://project",
			MIMEType: "application/json",
			Text:     string(config),
		},
	}, nil
}

The AI model can read resources at any point during the conversation to gather context. This is how our noted knowledge base works – it exposes project documentation as MCP resources so the model can look up architectural decisions, API specifications, and prior context without the user needing to paste anything.

Testing Strategies

MCP servers are straightforward to test because tools are just functions. Test them directly without spinning up the full protocol layer.

func TestHandleSearch(t *testing.T) {
	// Create a temp directory with known content
	dir := t.TempDir()
	os.WriteFile(
		filepath.Join(dir, "main.go"),
		[]byte("package main\nfunc hello() {}"),
		0644,
	)

	req := mcp.CallToolRequest{}
	req.Params.Arguments = map[string]any{
		"query": "hello",
		"scope": dir,
	}

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

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

	// Verify the result contains our test file
	text := result.Content[0].(mcp.TextContent).Text
	if !strings.Contains(text, "main.go") {
		t.Errorf("expected result to contain main.go, got: %s", text)
	}
}

For integration tests, use the client package’s in-process transport. Add github.com/mark3labs/mcp-go/client to the test file’s imports, then start and initialize the client before making protocol requests:

func TestServerIntegration(t *testing.T) {
	s := server.NewMCPServer("test", "0.1.0",
		server.WithToolCapabilities(true),
	)
	registerTools(s)

	testClient, err := client.NewInProcessClient(s)
	if err != nil {
		t.Fatalf("failed to create client: %v", err)
	}
	t.Cleanup(func() { _ = testClient.Close() })

	ctx := context.Background()
	if err := testClient.Start(ctx); err != nil {
		t.Fatalf("failed to start client: %v", err)
	}

	_, err = testClient.Initialize(ctx, mcp.InitializeRequest{
		Params: mcp.InitializeParams{
			ClientInfo: mcp.Implementation{Name: "integration-test", Version: "1.0.0"},
		},
	})
	if err != nil {
		t.Fatalf("failed to initialize client: %v", err)
	}

	// List tools and verify registration
	tools, err := testClient.ListTools(ctx, mcp.ListToolsRequest{})
	if err != nil {
		t.Fatalf("failed to list tools: %v", err)
	}

	if len(tools.Tools) != 1 {
		t.Fatalf("expected 1 tool, got %d", len(tools.Tools))
	}

	if tools.Tools[0].Name != "search_codebase" {
		t.Errorf("expected tool name search_codebase, got %s", tools.Tools[0].Name)
	}
}

This two-tier testing approach – unit tests for handlers, integration tests for protocol behavior – catches issues at both the logic and transport layers.

Deployment

Single Binary

The simplest deployment is a compiled binary. Cross-compile for every platform your users need:

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

Distribute via GitHub releases, Homebrew, or a simple download link. Users configure their AI client to launch the binary:

{
  "mcpServers": {
    "my-tool": {
      "command": "/usr/local/bin/my-mcp-server",
      "args": ["--project", "/path/to/project"]
    }
  }
}

Docker

For MCP servers that require external dependencies (databases, system libraries), Docker works well:

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

FROM alpine:3.19
RUN apk add --no-cache ca-certificates
COPY --from=builder /mcp-server /usr/local/bin/mcp-server
ENTRYPOINT ["/usr/local/bin/mcp-server"]

The multi-stage build keeps the final image focused on the binary and runtime certificates. Its exact size depends on the binary and base image, so measure the artifact you publish.

Remote Servers with Streamable HTTP

For shared or team-wide MCP servers, use the current Streamable HTTP transport instead of the legacy HTTP+SSE transport. Streamable HTTP exposes one MCP endpoint for POST and optional GET requests; responses may still use Server-Sent Events when streaming is useful. The mcp-go transport documentation covers the implementation-specific server options.

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

	httpServer := server.NewStreamableHTTPServer(
		s,
		server.WithEndpointPath("/mcp"),
	)

	fmt.Fprintln(os.Stderr, "MCP endpoint listening on http://localhost:8080/mcp")
	if err := httpServer.Start(":8080"); err != nil {
		fmt.Fprintf(os.Stderr, "server error: %v\n", err)
		os.Exit(1)
	}
}

The March 2025 MCP specification replaced the old transport that used separate SSE and message endpoints. Keep the legacy server only when you explicitly need compatibility with older clients. For anything reachable beyond localhost, add authentication and TLS, validate the Origin header, and scope every tool to the least authority it needs.

Lessons from Production

Across the MCP servers we build and run in Go, these practices have held up:

Start with stdio, add Streamable HTTP deliberately. The stdio transport is simpler to develop and debug. Build your server with stdio first, get the tools right, then add Streamable HTTP when you need remote or multi-client access. Streaming over SSE remains an option inside that transport; the old standalone HTTP+SSE transport is only for backward compatibility.

Version your tool schemas. When you change a tool’s parameters, older clients may send requests with the old shape. Handle missing fields gracefully rather than crashing.

Log to stderr. Stdout is the MCP transport channel. Any stray fmt.Println will corrupt the protocol stream and crash the connection. Use log.SetOutput(os.Stderr) at the top of your main function.

Measure tool latency. AI models have timeout expectations. If your tool takes more than a few seconds, the user experience degrades. Add instrumentation early and optimize the slow paths.

Go’s combination of fast compilation, a compact runtime, and straightforward concurrency makes it a strong fit for MCP servers distributed as standalone developer tools. Projects such as tinyvault and vecgrep are public examples of that approach.

If you are exploring MCP for your own development workflow, check out our open-source tools or reach out about our AI-native development services. We are always interested in what people are building with the protocol.

A

Abdul Hamid Achik

Founder and lead engineer at The Lacanians. Abdul builds production software, developer tools, and local-first systems from Guadalajara for teams worldwide.