This is the Go companion to Build an MCP Server in TypeScript with the Official SDK. It builds the same kind of server — two tools and a resource — with the official Go SDK (github.com/modelcontextprotocol/go-sdk), served over the stdio transport so a local client launches it as a subprocess. The result is a single compiled binary with no runtime beyond itself. The project uses the cmd/ + internal/ layout, a Makefile, and Go’s built-in testing.

The Go SDK leans on generics: mcp.AddTool infers a tool’s input schema from a Go struct, so you describe arguments with struct tags and the handler receives them already decoded and typed.

What you will build

  • An mcp.Server with two tools (add, greet) and a resource (info://server).
  • Pure tool logic in its own package, unit-tested without the SDK.
  • A stdio entry point compiled to bin/mcp-server.
  • A smoke command that launches the built binary and speaks MCP to it, plus a go test suite that drives the server in-process over an in-memory transport.
  • Registration with Claude Code.

Prerequisites

  • Go 1.24+ (brew install go on macOS). Verify go version.
  • Familiarity with MCP concepts (tools, resources, transports).
  • macOS commands are shown, but the project is cross-platform.

Step 1: Scaffold and initialize the module

Create the files

mkdir -p mcp-server-go
cd mcp-server-go
touch .gitignore
go mod init macmcp

Add the code: .gitignore

# Go
bin/
*.test
*.out
# OS / editor noise
.DS_Store

Detailed breakdown

  • go mod init macmcp creates go.mod with the module path macmcp, which prefixes the internal import paths (macmcp/internal/tools).
  • bin/ holds the compiled binary and is rebuilt, so it is ignored.

Step 2: Add the SDK

Create the file

go get github.com/modelcontextprotocol/go-sdk/mcp

Add the code: go.mod

module macmcp

go 1.26.5

require github.com/modelcontextprotocol/go-sdk v1.6.1

require (
	github.com/google/jsonschema-go v0.4.3 // indirect
	github.com/segmentio/asm v1.1.3 // indirect
	github.com/segmentio/encoding v0.5.4 // indirect
	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
	golang.org/x/oauth2 v0.35.0 // indirect
	golang.org/x/sys v0.41.0 // indirect
)

Detailed breakdown

  • github.com/modelcontextprotocol/go-sdk is the official SDK. The mcp package holds the server, client, transports, and message types.
  • The indirect requirements come in transitively: jsonschema-go builds the JSON Schema for tool inputs, and uritemplate handles resource templates. Your exact versions may differ; go get resolves them.

Step 3: The tool logic

Keep the actual work in its own package, free of any SDK types, so it unit-tests directly.

Create the file

mkdir -p internal/tools
touch internal/tools/tools.go

Add the code: internal/tools/tools.go

// Package tools holds the pure logic behind the MCP tools, with no dependency
// on the MCP SDK. Keeping the work here means it unit-tests without a server,
// a transport, or a client.
package tools

import "strings"

// Add returns the sum of two integers.
func Add(a, b int) int {
	return a + b
}

// Greet builds a greeting for name, optionally shouting it.
func Greet(name string, shout bool) string {
	msg := "Hello, " + name + "!"
	if shout {
		return strings.ToUpper(msg)
	}
	return msg
}

Detailed breakdown

  • Plain functions, no MCP imports. The server package adapts them to the protocol; the tests call them directly.

Step 4: Construct the server

Build the mcp.Server, register the tools and resource, and return it. Keeping construction separate from the transport lets the tests attach an in-memory transport to the same server.

Create the file

mkdir -p internal/mcpserver
touch internal/mcpserver/server.go

Add the code: internal/mcpserver/server.go

// Package mcpserver builds the MCP server: it registers the tools and a
// resource and returns the *mcp.Server. Connecting it to a transport happens in
// the caller (cmd/mcp-server runs it on stdio), which lets the tests attach an
// in-memory transport to the same server.
package mcpserver

import (
	"context"
	"encoding/json"
	"strconv"

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

	"macmcp/internal/tools"
)

const (
	name    = "mcp-go"
	version = "0.1.0"
)

// addArgs and greetArgs describe each tool's input. The struct tags drive both
// JSON decoding and the JSON Schema the SDK advertises to clients: `json` names
// the field, `jsonschema` documents it.
type addArgs struct {
	A int `json:"a" jsonschema:"the first addend"`
	B int `json:"b" jsonschema:"the second addend"`
}

type greetArgs struct {
	Name  string `json:"name" jsonschema:"who to greet"`
	Shout bool   `json:"shout" jsonschema:"upper-case the greeting"`
}

// New constructs the server with its tools and resource registered.
func New() *mcp.Server {
	server := mcp.NewServer(&mcp.Implementation{Name: name, Version: version}, nil)

	// The generic AddTool infers the input schema from the args struct and
	// decodes arguments into it before the handler runs.
	mcp.AddTool(server, &mcp.Tool{
		Name:        "add",
		Description: "Add two integers and return the sum.",
	}, func(_ context.Context, _ *mcp.CallToolRequest, args addArgs) (*mcp.CallToolResult, any, error) {
		sum := tools.Add(args.A, args.B)
		return &mcp.CallToolResult{
			Content: []mcp.Content{&mcp.TextContent{Text: strconv.Itoa(sum)}},
		}, nil, nil
	})

	mcp.AddTool(server, &mcp.Tool{
		Name:        "greet",
		Description: "Return a greeting for a name.",
	}, func(_ context.Context, _ *mcp.CallToolRequest, args greetArgs) (*mcp.CallToolResult, any, error) {
		return &mcp.CallToolResult{
			Content: []mcp.Content{&mcp.TextContent{Text: tools.Greet(args.Name, args.Shout)}},
		}, nil, nil
	})

	// A read-only resource: static server metadata as JSON.
	server.AddResource(&mcp.Resource{
		Name:        "server-info",
		URI:         "info://server",
		Description: "Static metadata about this server.",
		MIMEType:    "application/json",
	}, func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
		body, err := json.Marshal(map[string]string{"name": name, "version": version})
		if err != nil {
			return nil, err
		}
		return &mcp.ReadResourceResult{
			Contents: []*mcp.ResourceContents{{
				URI:      req.Params.URI,
				MIMEType: "application/json",
				Text:     string(body),
			}},
		}, nil
	})

	return server
}

Detailed breakdown

  • mcp.NewServer(&mcp.Implementation{Name, Version}, nil) creates the server; the name and version are what a client sees at initialize.
  • mcp.AddTool is a generic package function, not a method. It infers the input type from the handler’s args parameter and builds the advertised JSON Schema from the struct’s json and jsonschema tags. Arguments arrive already decoded into the struct, so there is no manual parsing.
  • The handler returns (*mcp.CallToolResult, any, error). The middle value is optional structured output (nil here); the result carries Content, and &mcp.TextContent{Text: ...} is the text case.
  • server.AddResource(&mcp.Resource{...}, handler) is a plain method (no generics). The handler returns ReadResourceResult with one or more ResourceContents, each tagged with the URI it answers — read from req.Params.URI.
  • New returns the server unconnected, which is what lets Step 7’s tests use an in-memory transport instead of stdio.

Step 5: The stdio entry point

Create the file

mkdir -p cmd/mcp-server
touch cmd/mcp-server/main.go

Add the code: cmd/mcp-server/main.go

// Command mcp-server runs the MCP server over the stdio transport — the
// transport a local client (Claude Desktop, Claude Code) launches as a
// subprocess. The client speaks JSON-RPC over stdin/stdout, so all logging goes
// to stderr; the SDK owns stdout.
package main

import (
	"context"
	"log"

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

	"macmcp/internal/mcpserver"
)

func main() {
	// log writes to stderr by default, which is safe for the stdio transport.
	log.SetFlags(0)
	server := mcpserver.New()
	if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
		log.Fatalf("server failed: %v", err)
	}
}

Detailed breakdown

  • server.Run(ctx, &mcp.StdioTransport{}) serves until the client disconnects, reading JSON-RPC from stdin and writing it to stdout.
  • Logging goes to stderr. Go’s log package writes to stderr by default, which is exactly right here: stdout is the protocol channel, and a stray write to it corrupts the stream and disconnects the client. Never fmt.Println from a stdio server.

Step 6: Build and smoke-test

Compile the binary:

go build -o bin/mcp-server ./cmd/mcp-server

Then launch it the way a client does and call its tools.

Create the file

mkdir -p cmd/smoke
touch cmd/smoke/main.go

Add the code: cmd/smoke/main.go

// Command smoke launches the built server over stdio and speaks MCP to it, the
// way a real client does. A green run proves the exact command you register
// with a client works. Build first (`make build`), then `go run ./cmd/smoke`.
package main

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

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

func main() {
	ctx := context.Background()
	client := mcp.NewClient(&mcp.Implementation{Name: "smoke", Version: "0.1.0"}, nil)

	// CommandTransport spawns the built binary and connects over its stdio.
	transport := &mcp.CommandTransport{Command: exec.Command("bin/mcp-server")}
	session, err := client.Connect(ctx, transport, nil)
	if err != nil {
		log.Fatalf("connect: %v", err)
	}
	defer session.Close()

	tools, err := session.ListTools(ctx, nil)
	if err != nil {
		log.Fatalf("list tools: %v", err)
	}
	for _, t := range tools.Tools {
		fmt.Println("tool:", t.Name)
	}

	sum, err := session.CallTool(ctx, &mcp.CallToolParams{
		Name:      "add",
		Arguments: map[string]any{"a": 2, "b": 3},
	})
	if err != nil {
		log.Fatalf("call add: %v", err)
	}
	fmt.Println("add ->", text(sum))

	hi, err := session.CallTool(ctx, &mcp.CallToolParams{
		Name:      "greet",
		Arguments: map[string]any{"name": "Ada", "shout": true},
	})
	if err != nil {
		log.Fatalf("call greet: %v", err)
	}
	fmt.Println("greet ->", text(hi))

	res, err := session.ReadResource(ctx, &mcp.ReadResourceParams{URI: "info://server"})
	if err != nil {
		log.Fatalf("read resource: %v", err)
	}
	fmt.Println("resource ->", res.Contents[0].Text)
}

// text pulls the first text block out of a tool result.
func text(res *mcp.CallToolResult) string {
	if len(res.Content) == 0 {
		return ""
	}
	if tc, ok := res.Content[0].(*mcp.TextContent); ok {
		return tc.Text
	}
	return ""
}

Run it:

go run ./cmd/smoke
tool: add
tool: greet
add -> 5
greet -> HELLO, ADA!
resource -> {"name":"mcp-go","version":"0.1.0"}

Detailed breakdown

  • mcp.CommandTransport{Command: exec.Command("bin/mcp-server")} spawns the built binary and connects over its stdin/stdout — the client half of stdio.
  • session.CallTool sends Arguments as a map; the server decodes them into the tool’s struct. session.ReadResource fetches the resource by URI.
  • text type-asserts mcp.Content to *mcp.TextContent, since Content is an interface that also covers image and other block types.
  • The output confirms the whole path end to end, using the exact launch command you will register with a client.

Step 7: Tests

Unit-test the pure logic, then drive the server in-process with the SDK’s in-memory transport — no subprocess, no stdio.

Create the files

touch internal/tools/tools_test.go internal/mcpserver/server_test.go

Add the code: internal/tools/tools_test.go

package tools

import "testing"

func TestAdd(t *testing.T) {
	cases := []struct {
		a, b, want int
	}{
		{2, 3, 5},
		{-1, 1, 0},
	}
	for _, c := range cases {
		if got := Add(c.a, c.b); got != c.want {
			t.Errorf("Add(%d, %d) = %d, want %d", c.a, c.b, got, c.want)
		}
	}
}

func TestGreet(t *testing.T) {
	if got := Greet("Ada", false); got != "Hello, Ada!" {
		t.Errorf("Greet = %q, want %q", got, "Hello, Ada!")
	}
	if got := Greet("Ada", true); got != "HELLO, ADA!" {
		t.Errorf("Greet shout = %q, want %q", got, "HELLO, ADA!")
	}
}

Add the code: internal/mcpserver/server_test.go

package mcpserver

import (
	"context"
	"testing"

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

// connect wires a client to the real server in-process over an in-memory
// transport pair — no subprocess, no stdio.
func connect(t *testing.T) *mcp.ClientSession {
	t.Helper()
	ctx := context.Background()
	clientT, serverT := mcp.NewInMemoryTransports()

	if _, err := New().Connect(ctx, serverT, nil); err != nil {
		t.Fatalf("server connect: %v", err)
	}
	client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil)
	session, err := client.Connect(ctx, clientT, nil)
	if err != nil {
		t.Fatalf("client connect: %v", err)
	}
	t.Cleanup(func() { _ = session.Close() })
	return session
}

func textOf(t *testing.T, res *mcp.CallToolResult) string {
	t.Helper()
	if len(res.Content) == 0 {
		t.Fatal("no content in result")
	}
	tc, ok := res.Content[0].(*mcp.TextContent)
	if !ok {
		t.Fatalf("content is not text: %T", res.Content[0])
	}
	return tc.Text
}

func TestListTools(t *testing.T) {
	session := connect(t)
	res, err := session.ListTools(context.Background(), nil)
	if err != nil {
		t.Fatalf("list tools: %v", err)
	}
	got := map[string]bool{}
	for _, tool := range res.Tools {
		got[tool.Name] = true
	}
	for _, want := range []string{"add", "greet"} {
		if !got[want] {
			t.Errorf("missing tool %q", want)
		}
	}
}

func TestCallAdd(t *testing.T) {
	session := connect(t)
	res, err := session.CallTool(context.Background(), &mcp.CallToolParams{
		Name:      "add",
		Arguments: map[string]any{"a": 40, "b": 2},
	})
	if err != nil {
		t.Fatalf("call add: %v", err)
	}
	if got := textOf(t, res); got != "42" {
		t.Errorf("add = %q, want %q", got, "42")
	}
}

func TestReadResource(t *testing.T) {
	session := connect(t)
	res, err := session.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: "info://server"})
	if err != nil {
		t.Fatalf("read resource: %v", err)
	}
	want := `{"name":"mcp-go","version":"0.1.0"}`
	if got := res.Contents[0].Text; got != want {
		t.Errorf("resource = %q, want %q", got, want)
	}
}

Run them:

go test ./... -count=1
ok  	macmcp/internal/mcpserver	0.204s
ok  	macmcp/internal/tools	0.337s

Detailed breakdown

  • tools_test.go exercises the pure functions with a table test — no MCP involved.
  • server_test.go uses mcp.NewInMemoryTransports(), which returns a linked client/server transport pair. New().Connect(ctx, serverT, nil) starts the real server on one end and the client connects on the other, all in one process — faster and more deterministic than spawning a subprocess. It checks the advertised tools, the tool result (40 + 2 = 42), and the resource JSON.
  • -count=1 disables Go’s test caching so a run always executes.

Step 8: The Makefile

Wrap the commands so plain make prints help.

Create the file

touch Makefile

Add the code: Makefile

.DEFAULT_GOAL := help

BINARY := bin/mcp-server
DIR    := $(shell pwd)

.PHONY: help build test smoke run register-code unregister-code clean

help: ## Show this help screen
	@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*## "}; {printf "  %-16s %s\n", $$1, $$2}'

build: ## Build the server binary
	go build -o $(BINARY) ./cmd/mcp-server

test: ## Run all tests
	go test ./... -count=1

smoke: build ## Build, then launch the server over stdio and call its tools
	go run ./cmd/smoke

run: build ## Run the server on stdio (Ctrl-C to stop)
	./$(BINARY)

register-code: build ## Register the built server with Claude Code (local scope)
	claude mcp add mcp-go -- $(DIR)/$(BINARY)

unregister-code: ## Remove the server from Claude Code
	-claude mcp remove mcp-go

clean: ## Remove build artifacts
	rm -rf bin/

Detailed breakdown

  • Plain make prints the help screen listing every target.
  • register-code depends on build, so the binary a client points at is current, and it uses an absolute path ($(DIR)) because a client may launch the server from any directory.

Step 9: Register with Claude Code

The server is a compiled stdio program, so register it by its binary path (see Register a FastMCP Server with Claude Desktop and Claude Code for scopes and the Claude Desktop config). From the project directory, after make build:

claude mcp add mcp-go -- "$(pwd)/bin/mcp-server"
claude mcp get mcp-go
mcp-go:
  Scope: Local config (private to you in this project)
  Status: ✔ Connected
  Type: stdio
  Command: /Users/you/mcp-server-go/bin/mcp-server

✔ Connected means Claude Code launched the binary and completed the MCP handshake. Start a session and ask it to use mcp-go to add 2 and 3.

Troubleshooting

  • go build fails on the SDK import. Run go get github.com/modelcontextprotocol/go-sdk/mcp and go mod tidy. The module path ends in /mcp for the package but the require line is the repo root.
  • The client connects but sees no tools, or disconnects at once. Something wrote to stdout. Use log (stderr) for diagnostics; never fmt.Print* from a stdio server.
  • AddTool does not compile. It is the generic package function mcp.AddTool(server, tool, handler), not a method. The handler’s third parameter is your args struct; its schema comes from the struct tags.
  • Arguments arrive zero-valued. The json tag names must match the argument keys the client sends (a, b, name, shout here).
  • claude mcp get shows Failed to connect. Run ./bin/mcp-server directly — it should start and wait. If it exits immediately, rebuild with make build; the binary may be missing or stale.

Recap

  • The official Go SDK builds an MCP server from mcp.NewServer plus the generic mcp.AddTool (input schema inferred from a tagged struct) and server.AddResource.
  • server.Run(ctx, &mcp.StdioTransport{}) serves it as a subprocess; stdout is the protocol, so logging uses stderr — Go’s default.
  • mcp.NewInMemoryTransports() links a client to the server in-process for fast tests, alongside a subprocess smoke command over real stdio.
  • The compiled binary registers with any MCP client by its path.

Next improvements

  • Add the Streamable HTTP transport for remote clients (see Deploy an MCP Server to Cloudflare Workers for a hosted server), keeping stdio for local use.
  • Return structured output from a tool via the middle return value of the handler, typed by an output struct, for clients that consume it.
  • Add prompts with server.AddPrompt, exposing reusable templates next to the tools.
  • Cross-compile and distribute the binary (GOOS/GOARCH) so users install a single file with no toolchain.