Build MCP servers

How to build an MCP server in Go

A notes server in Go with the official SDK, running over stdio and Streamable HTTP, checked with the SDK's own client and set up for Claude Code.

By
Dan Flanagan
Updated
October 14, 2026
Tested with
Go 1.27.0 and github.com/modelcontextprotocol/go-sdk v1.8.0 on macOS 26.7.1; Claude Code step not re-run
Reading time
5 min

New to MCP? What is an MCP server? covers the basics first.

The Go SDK builds each tool's JSON Schema from your Go structs and their tags, so the notes server below is two files with no schema written by hand. list_notes and read_note only read, and add_note writes a file, which matters later when you decide which agents get which tools.

Every command below was run on the versions in the header. Minor releases of the SDK can still change behavior to fix bugs or security issues, so pin the version in go.mod and read docs/mcpgodebug.md in the SDK repository before upgrading.

Prerequisites

  • Go 1.23 or newer. I used 1.27.0.
  • Claude Code, if you want to try the server there in step 5.

Step 1: Create the project

Make a folder, start a module, and add the SDK:

bash
mkdir notes-server && cd notes-server
go mod init notes-server
go get github.com/modelcontextprotocol/go-sdk/mcp@latest

That printed:

text
go: added github.com/google/jsonschema-go v0.4.3
go: added github.com/modelcontextprotocol/go-sdk v1.8.0
go: added github.com/segmentio/asm v1.1.3
go: added github.com/segmentio/encoding v0.5.4
go: added github.com/yosida95/uritemplate/v3 v3.0.2
go: added golang.org/x/oauth2 v0.35.0
go: added golang.org/x/sync v0.20.0
go: added golang.org/x/sys v0.41.0
go: added golang.org/x/time v0.15.0

Naming the mcp package makes go get record that package's dependencies too, so the first build finds every go.sum entry it needs. go.mod now pins the SDK, marked // indirect because no code imports it yet:

text
require (
	github.com/google/jsonschema-go v0.4.3 // indirect
	github.com/modelcontextprotocol/go-sdk v1.8.0 // 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/sync v0.20.0 // indirect
	golang.org/x/sys v0.41.0 // indirect
	golang.org/x/time v0.15.0 // indirect
)

go mod tidy in step 3 moves it to a direct requirement once the code exists.

Step 2: Define the tools with struct tags

Put the tools in their own file so the stdio and HTTP entry points can share them. Create notes.go:

go
package main

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"

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

var slugPattern = regexp.MustCompile(`[^a-z0-9-]+`)

func notesDir() string {
	dir := os.Getenv("NOTES_DIR")
	if dir == "" {
		dir = "notes"
	}
	abs, err := filepath.Abs(dir)
	if err != nil {
		return dir
	}
	return abs
}

// notePath maps a title to a file inside NOTES_DIR, and refuses anything
// that would escape it.
func notePath(dir, title string) (string, error) {
	slug := strings.Trim(slugPattern.ReplaceAllString(strings.ToLower(title), "-"), "-")
	if slug == "" {
		return "", fmt.Errorf("a note needs a title with at least one letter or number")
	}
	return filepath.Join(dir, slug+".md"), nil
}

type ListNotesOutput struct {
	Titles []string `json:"titles" jsonschema:"the titles of every saved note"`
}

func listNotes(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, ListNotesOutput, error) {
	dir := notesDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return nil, ListNotesOutput{}, err
	}
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, ListNotesOutput{}, err
	}
	var titles []string
	for _, e := range entries {
		if strings.HasSuffix(e.Name(), ".md") {
			titles = append(titles, strings.TrimSuffix(e.Name(), ".md"))
		}
	}
	sort.Strings(titles)
	return nil, ListNotesOutput{Titles: titles}, nil
}

type ReadNoteInput struct {
	Title string `json:"title" jsonschema:"the title of the note to read"`
}

type ReadNoteOutput struct {
	Body string `json:"body" jsonschema:"the text of the note"`
}

func readNote(_ context.Context, _ *mcp.CallToolRequest, in ReadNoteInput) (*mcp.CallToolResult, ReadNoteOutput, error) {
	path, err := notePath(notesDir(), in.Title)
	if err != nil {
		return nil, ReadNoteOutput{}, err
	}
	body, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, ReadNoteOutput{}, fmt.Errorf("no note called %q", in.Title)
		}
		return nil, ReadNoteOutput{}, err
	}
	return nil, ReadNoteOutput{Body: string(body)}, nil
}

type AddNoteInput struct {
	Title string `json:"title" jsonschema:"the title of the note"`
	Body  string `json:"body" jsonschema:"the text to save"`
}

type AddNoteOutput struct {
	Saved string `json:"saved" jsonschema:"the title that was saved"`
}

func addNote(_ context.Context, _ *mcp.CallToolRequest, in AddNoteInput) (*mcp.CallToolResult, AddNoteOutput, error) {
	dir := notesDir()
	path, err := notePath(dir, in.Title)
	if err != nil {
		return nil, AddNoteOutput{}, err
	}
	if _, err := os.Stat(path); err == nil {
		return nil, AddNoteOutput{}, fmt.Errorf("a note called %q already exists", in.Title)
	}
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return nil, AddNoteOutput{}, err
	}
	if err := os.WriteFile(path, []byte(in.Body), 0o644); err != nil {
		return nil, AddNoteOutput{}, err
	}
	return nil, AddNoteOutput{Saved: strings.TrimSuffix(filepath.Base(path), ".md")}, nil
}

func newNotesServer() *mcp.Server {
	server := mcp.NewServer(&mcp.Implementation{Name: "notes", Version: "v1.0.0"}, nil)

	mcp.AddTool(server, &mcp.Tool{
		Name:        "list_notes",
		Description: "List the titles of every saved note.",
		Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
	}, listNotes)

	mcp.AddTool(server, &mcp.Tool{
		Name:        "read_note",
		Description: "Return the text of one note.",
		Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
	}, readNote)

	destructive := false
	mcp.AddTool(server, &mcp.Tool{
		Name:        "add_note",
		Description: "Save a note. Fails if a note with that title already exists.",
		Annotations: &mcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: &destructive},
	}, addNote)

	return server
}

Then main.go:

go
package main

import (
	"context"
	"flag"
	"log"
	"net/http"

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

func main() {
	addr := flag.String("http", "", "serve Streamable HTTP on this address instead of stdio, e.g. 127.0.0.1:8000")
	flag.Parse()

	server := newNotesServer()

	if *addr != "" {
		handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
			return server
		}, nil)
		log.Printf("notes server on http://%s/mcp", *addr)
		if err := http.ListenAndServe(*addr, handler); err != nil {
			log.Fatal(err)
		}
		return
	}

	if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
		log.Fatal(err)
	}
}

The struct tags are the schema. AddTool reads the In and Out type parameters of the handler by reflection and builds a JSON Schema from their fields. json names the field, jsonschema describes it for the model that reads the tool list. There's no decorator or manual schema object to keep in sync.

Titles never reach the filesystem as typed. notePath turns every title into a slug, so ../../etc/passwd becomes etc-passwd.md inside the notes folder. A model's tool arguments can trace back to a document or issue it just read, so treat them like form input.

Return an ordinary error for failures you expect. AddTool catches it, puts the message in the result's content, and sets IsError to true. The client and the model see the message; nothing panics and nothing crashes the process.

Annotations are hints. ReadOnlyHint: true tells a client the tool changes nothing, which it can use when deciding what to confirm with you. The SDK does not enforce it, so a tool marked read-only can still write files if its code does.

server.Run with &mcp.StdioTransport{} serves over stdin and stdout, which is what Claude Code launches locally. Standard output carries the protocol there, so route anything for humans, including log.Print, to standard error; the log package does that by default.

Step 3: Test it with a client

Build the server first, then write a client against it. Create client/main.go:

go
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: "notes-check", Version: "v1.0.0"}, nil)

	transport := &mcp.CommandTransport{Command: exec.Command("notes-server")}
	session, err := client.Connect(ctx, transport, nil)
	if err != nil {
		log.Fatal(err)
	}
	defer session.Close()

	tools, err := session.ListTools(ctx, nil)
	if err != nil {
		log.Fatal(err)
	}
	for _, tool := range tools.Tools {
		fmt.Println(tool.Name)
	}

	_, err = session.CallTool(ctx, &mcp.CallToolParams{
		Name:      "add_note",
		Arguments: map[string]any{"title": "Release checklist", "body": "Tag, build, announce."},
	})
	if err != nil {
		log.Fatal(err)
	}

	listed, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "list_notes", Arguments: map[string]any{}})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(listed.StructuredContent)

	note, err := session.CallTool(ctx, &mcp.CallToolParams{
		Name:      "read_note",
		Arguments: map[string]any{"title": "release checklist"},
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(note.StructuredContent)

	dup, err := session.CallTool(ctx, &mcp.CallToolParams{
		Name:      "add_note",
		Arguments: map[string]any{"title": "Release checklist", "body": "again"},
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(dup.IsError, dup.Content[0].(*mcp.TextContent).Text)
}

Tidy the module, build both binaries, and run the client with the server on PATH, since CommandTransport runs the command by name:

bash
go mod tidy
go build -o notes-server .
go build -o client/notes-client ./client
PATH="$PWD:$PATH" ./client/notes-client
text
add_note
list_notes
read_note
map[titles:[release-checklist]]
map[body:Tag, build, announce.]
true a note called "Release checklist" already exists

ListTools came back in alphabetical order, not the order the tools were added in; the SDK keeps the server's tool set as a lookup table, not a list. The client started notes-server as a subprocess over its stdin and stdout, the same way Claude Code will. CommandTransport wraps an *exec.Cmd, so anything you can do to a normal subprocess, setting its working directory or environment, works here too.

Step 4: Serve it over Streamable HTTP with net/http

Running with -http switches the same server to the network instead of stdio:

bash
./notes-server -http 127.0.0.1:8000
text
2026/10/14 16:38:22 notes server on http://127.0.0.1:8000/mcp

NewStreamableHTTPHandler turns any *mcp.Server into a standard http.Handler. With no options it checks the Host header: a request that arrives on a localhost address but carries a non-localhost Host gets 403 Forbidden, which blocks DNS rebinding. It does not check Origin unless you set StreamableHTTPOptions.CrossOriginProtection.

bash
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: evil.example" -X POST http://127.0.0.1:8000/mcp
text
403

To call it for real, point the SDK's StreamableClientTransport at the URL. Create httpclient/main.go:

go
package main

import (
	"context"
	"fmt"
	"log"

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

func main() {
	ctx := context.Background()
	client := mcp.NewClient(&mcp.Implementation{Name: "notes-check", Version: "v1.0.0"}, nil)

	session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: "http://127.0.0.1:8000/mcp"}, nil)
	if err != nil {
		log.Fatal(err)
	}
	defer session.Close()

	dup, err := session.CallTool(ctx, &mcp.CallToolParams{
		Name:      "add_note",
		Arguments: map[string]any{"title": "Release checklist", "body": "again"},
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(dup.IsError, dup.Content[0].(*mcp.TextContent).Text)

	odd, err := session.CallTool(ctx, &mcp.CallToolParams{
		Name:      "read_note",
		Arguments: map[string]any{"title": "../../etc/passwd"},
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(odd.IsError, odd.Content[0].(*mcp.TextContent).Text)
}
bash
go build -o httpclient/notes-httpclient ./httpclient
./httpclient/notes-httpclient
text
true a note called "Release checklist" already exists
true no note called "../../etc/passwd"

Underneath, StreamableClientTransport still runs the classic initialize and notifications/initialized exchange before the first tool call; this build of the SDK negotiates that down to protocol version 2025-11-25 for its default stateful HTTP mode, confirmed by sending the same requests with curl and reading the Mcp-Session-Id header back. The newer spec version, 2026-07-28, drops that handshake for a stateless server, which this SDK also supports if you set StreamableHTTPOptions.Stateless: true, but that is a different deployment shape and this tutorial does not use it.

The server binds to 127.0.0.1, so only your machine can reach it. Put authentication in front of it before it goes anywhere public, or it will run its tools for anyone who finds the URL.

Step 5: Add it to Claude Code

From the project folder, build a binary and register the stdio version for this project:

bash
go build -o notes-server .
claude mcp add --scope project notes -- "$PWD/notes-server"

That writes a .mcp.json file in the folder:

json
{
  "mcpServers": {
    "notes": {
      "type": "stdio",
      "command": "/path/to/notes-server/notes-server",
      "args": [],
      "env": {}
    }
  }
}

claude mcp get notes shows it as pending approval. Claude Code asks before running a project server, because .mcp.json gets committed and cloning a repository shouldn't run a binary on your machine. Start claude in the folder, approve it, and ask it to save a note.

To use the HTTP version instead, with the server from step 4 running:

bash
claude mcp add --scope project --transport http notes http://127.0.0.1:8000/mcp

Claude Code reads its servers when a session starts, so open a new session after adding one.

Step 6: Put it behind Statio

Statio is a gateway that grants tools one at a time, so list_notes and read_note can go to the agents that look things up and add_note only to the one that writes. Connect the HTTP version by URL on any plan, or from the Team plan up give Statio a Docker image to run.

The setup is in Connect a client and Permissions, and MCP permissions compares the other ways to limit an agent's tools.

Conclusion

You now have a Go MCP server running as one binary over stdio or Streamable HTTP and set up for Claude Code. Most of the work sits in the handler signature: the input and output structs become the tool's schema, and a returned error becomes a result the model can read.

Where to go next: