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.Serverwith 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 testsuite that drives the server in-process over an in-memory transport. - Registration with Claude Code.
Prerequisites
- Go 1.24+ (
brew install goon macOS). Verifygo 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 macmcpcreatesgo.modwith the module pathmacmcp, 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-sdkis the official SDK. Themcppackage holds the server, client, transports, and message types.- The indirect requirements come in transitively:
jsonschema-gobuilds the JSON Schema for tool inputs, anduritemplatehandles resource templates. Your exact versions may differ;go getresolves 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.AddToolis a generic package function, not a method. It infers the input type from the handler’sargsparameter and builds the advertised JSON Schema from the struct’sjsonandjsonschematags. 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 carriesContent, and&mcp.TextContent{Text: ...}is the text case. server.AddResource(&mcp.Resource{...}, handler)is a plain method (no generics). The handler returnsReadResourceResultwith one or moreResourceContents, each tagged with theURIit answers — read fromreq.Params.URI.Newreturns 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
logpackage 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. Neverfmt.Printlnfrom 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.CallToolsendsArgumentsas a map; the server decodes them into the tool’s struct.session.ReadResourcefetches the resource by URI.texttype-assertsmcp.Contentto*mcp.TextContent, sinceContentis 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.goexercises the pure functions with a table test — no MCP involved.server_test.gousesmcp.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=1disables 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
makeprints the help screen listing every target. register-codedepends onbuild, 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 buildfails on the SDK import. Rungo get github.com/modelcontextprotocol/go-sdk/mcpandgo mod tidy. The module path ends in/mcpfor 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; neverfmt.Print*from a stdio server. AddTooldoes not compile. It is the generic package functionmcp.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
jsontag names must match the argument keys the client sends (a,b,name,shouthere). claude mcp getshowsFailed to connect. Run./bin/mcp-serverdirectly — it should start and wait. If it exits immediately, rebuild withmake build; the binary may be missing or stale.
Recap
- The official Go SDK builds an MCP server from
mcp.NewServerplus the genericmcp.AddTool(input schema inferred from a tagged struct) andserver.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.