Skip to main content

How to Build an MCP Server in Go

Short answer

Write an MCP server in Go with the official Go SDK, github.com/modelcontextprotocol/go-sdk. Each tool is a Go function with a typed input struct, registered with mcp.AddTool, and server.Run serves the tools over stdio so an AI app can start the program and call them. This guide builds the same bookmarks server as How to Build an MCP Server, in one file of about 180 lines, and tests it with MCP Inspector in both protocol eras.

Tested withMCP spec 2026-07-28Go SDK (github.com/modelcontextprotocol/go-sdk) v1.8.0Go 1.25.4MCP Inspector 2.11.0Node.js 22.19Checked 20 Oct 2026

What you'll have at the end​

A bookmarks server with two tools, save_bookmark and search_bookmarks. This is the result of calling save_bookmark from MCP Inspector:

{
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "bookmarks",
"version": "1.0.0"
}
},
"content": [
{
"type": "text",
"text": "Saved 'MCP docs'. Total bookmarks: 1"
}
]
}

The tools behave the same as the Python and TypeScript versions in the reference guide. Only the language changes, so you can compare the two side by side.

Before you start

  • Go 1.25 or later. The SDK's go.mod requires Go 1.25.0.
  • Node.js 22.19 or later, which MCP Inspector needs. An older Node version gives misleading failures.
  • Basic Go and a terminal.

1. Set up the module​

mkdir bookmarks
cd bookmarks
go mod init bookmarks
go get github.com/modelcontextprotocol/go-sdk@v1.8.0

go get adds the SDK to go.mod. Pinning @v1.8.0 means you build the version this guide was tested with. After you save main.go in step 2, run go mod tidy to fill in the remaining dependencies. Your go.mod should look like this. The go line can differ on your machine, as long as it's 1.25.0 or later.

go.mod
module bookmarks

go 1.25.0

require (
github.com/google/jsonschema-go v0.4.3
github.com/modelcontextprotocol/go-sdk v1.8.0
)

require (
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
)

2. Write the server​

Save this as main.go:

main.go
package main

import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log"
"net/url"
"os"
"path/filepath"
"reflect"
"strings"

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

type Bookmark struct {
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
}

type SaveBookmarkInput struct {
URL string `json:"url" jsonschema:"Full URL of the page, including https://"`
Title string `json:"title" jsonschema:"Short human-readable title for the page"`
Tags []string `json:"tags,omitempty" jsonschema:"Optional lowercase tags, for example recipes or python"`
}

type SearchBookmarksInput struct {
Query string `json:"query" jsonschema:"One or more words to look for, case-insensitive"`
}

// bookmarkStore keeps every bookmark in one JSON file. The tool handlers
// are methods on it so they share the file path.
type bookmarkStore struct {
path string
}

func (s bookmarkStore) load() ([]Bookmark, error) {
data, err := os.ReadFile(s.path)
if errors.Is(err, fs.ErrNotExist) {
return []Bookmark{}, nil
}
if err != nil {
return nil, err
}
var bookmarks []Bookmark
if err := json.Unmarshal(data, &bookmarks); err != nil {
return nil, fmt.Errorf("could not parse %s: %w", s.path, err)
}
return bookmarks, nil
}

func (s bookmarkStore) save(bookmarks []Bookmark) error {
data, err := json.MarshalIndent(bookmarks, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0o644)
}

func (s bookmarkStore) saveBookmark(_ context.Context, _ *mcp.CallToolRequest, in SaveBookmarkInput) (*mcp.CallToolResult, any, error) {
if err := checkURL(in.URL); err != nil {
return nil, nil, err
}
bookmarks, err := s.load()
if err != nil {
return nil, nil, err
}
for _, b := range bookmarks {
if b.URL == in.URL {
return textResult("Already saved: " + in.URL), nil, nil
}
}
tags := in.Tags
if tags == nil {
tags = []string{}
}
bookmarks = append(bookmarks, Bookmark{URL: in.URL, Title: in.Title, Tags: tags})
if err := s.save(bookmarks); err != nil {
return nil, nil, err
}
log.Printf("Saved bookmark %s", in.URL)
return textResult(fmt.Sprintf("Saved '%s'. Total bookmarks: %d", in.Title, len(bookmarks))), nil, nil
}

func (s bookmarkStore) searchBookmarks(_ context.Context, _ *mcp.CallToolRequest, in SearchBookmarksInput) (*mcp.CallToolResult, any, error) {
bookmarks, err := s.load()
if err != nil {
return nil, nil, err
}
words := strings.Fields(strings.ToLower(in.Query))
var lines []string
for _, b := range bookmarks {
haystack := strings.ToLower(strings.Join(append([]string{b.Title, b.URL}, b.Tags...), " "))
if containsAll(haystack, words) {
lines = append(lines, fmt.Sprintf("- %s: %s", b.Title, b.URL))
}
}
if len(lines) == 0 {
return textResult(fmt.Sprintf("No bookmarks match '%s'.", in.Query)), nil, nil
}
return textResult(strings.Join(lines, "\n")), nil, nil
}

func containsAll(haystack string, words []string) bool {
for _, w := range words {
if !strings.Contains(haystack, w) {
return false
}
}
return true
}

func checkURL(raw string) error {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("url must be a full http or https address, including https://: %q", raw)
}
return nil
}

// textResult wraps plain text in a tool result. Handlers return it with a nil
// output value, so the SDK doesn't add structured content or an output schema.
func textResult(text string) *mcp.CallToolResult {
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}
}

func bookmarksPath() (string, error) {
if p := os.Getenv("BOOKMARKS_FILE"); p != "" {
return p, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".bookmarks-mcp.json"), nil
}

func main() {
path, err := bookmarksPath()
if err != nil {
log.Fatal(err)
}
store := bookmarkStore{path: path}

// By default the SDK marks slices as nullable (["null", "array"]). Map
// []string to a plain array so the schema works with stricter model providers.
saveSchema, err := jsonschema.For[SaveBookmarkInput](&jsonschema.ForOptions{
TypeSchemas: map[reflect.Type]*jsonschema.Schema{
reflect.TypeFor[[]string](): {Type: "array", Items: &jsonschema.Schema{Type: "string"}},
},
})
if err != nil {
log.Fatal(err)
}

server := mcp.NewServer(&mcp.Implementation{Name: "bookmarks", Version: "1.0.0"}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "save_bookmark",
Description: "Save a web page to the user's bookmarks. Use this when the user asks to save, bookmark or remember a link.",
InputSchema: saveSchema,
}, store.saveBookmark)
mcp.AddTool(server, &mcp.Tool{
Name: "search_bookmarks",
Description: "Search saved bookmarks by words in the title, URL or tags.",
}, store.searchBookmarks)

// Logs go to stderr. Anything written to stdout corrupts the stdio transport.
log.Printf("Bookmarks MCP server running on stdio")
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}

mcp.AddTool registers a tool and builds its input schema from the Go struct you pass as the input type. Each field's json tag sets the parameter name, and its jsonschema tag sets the description the model sees. A field tagged omitempty is optional. Fields without it are required, so url and title must be provided and tags can be left out.

The handler has the shape func(context.Context, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error). These tools return plain text, the same as the reference server, so Out is any and each handler builds its own CallToolResult. If a handler returns an error, the SDK sends it back as a tool error with isError set to true. The model sees the message and can correct its input. It isn't a protocol error.

The tags slice needs one extra step. By default the SDK marks slices as nullable, which makes the schema ["null", "array"]. The TypeSchemas option in jsonschema.ForOptions replaces that with a plain array. Some clients map tool schemas onto a single-type format and can reject or drop a multi-type one, so the server asks for the plain form.

server.Run with &mcp.StdioTransport{} serves the connection over standard input and output until the client disconnects. The SDK negotiates the protocol version with each client, so this one server answers apps on the 2026-07-28 revision and apps that use the older initialize handshake. You don't write separate code for each.

Never write to stdout

Over stdio, standard output carries the protocol messages. Text printed with fmt.Println ends up in that stream and can disconnect the client. This server logs with log, which writes to stderr.

3. Build it​

go build -o build/bookmarks .

This writes the program to build/bookmarks. MCP Inspector and your AI app both start that file.

4. Test it with MCP Inspector​

MCP Inspector connects to the server the same way an AI app does. Its command-line mode is the fastest check. List the tools in the current protocol era:

npx @modelcontextprotocol/inspector@2.11.0 --cli ./build/bookmarks --protocol-era modern --method tools/list

You should see both tools. The save_bookmark entry looks like this:

{
"tools": [
{
"name": "save_bookmark",
"description": "Save a web page to the user's bookmarks. Use this when the user asks to save, bookmark or remember a link.",
"inputSchema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Full URL of the page, including https://"
},
"title": {
"type": "string",
"description": "Short human-readable title for the page"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional lowercase tags, for example recipes or python"
}
},
"required": [
"url",
"title"
],
"additionalProperties": false
}
},
...

Inspector defaults to the legacy era, which exercises the older handshake. Run the same command with --protocol-era legacy so older apps are covered too. Both runs should list save_bookmark and search_bookmarks.

Next, call a tool. The -e flag sets an environment variable for the server, and BOOKMARKS_FILE chooses the file it writes. Without it, the server uses ~/.bookmarks-mcp.json.

npx @modelcontextprotocol/inspector@2.11.0 --cli -e BOOKMARKS_FILE=/tmp/bookmarks-test.json ./build/bookmarks \
--protocol-era modern --method tools/call \
--tool-name save_bookmark --tool-arg url=https://modelcontextprotocol.io --tool-arg title="MCP docs"

Then search for it:

npx @modelcontextprotocol/inspector@2.11.0 --cli -e BOOKMARKS_FILE=/tmp/bookmarks-test.json ./build/bookmarks \
--protocol-era modern --method tools/call \
--tool-name search_bookmarks --tool-arg query=mcp

The search returns - MCP docs: https://modelcontextprotocol.io.

The example folder includes a test.sh that runs these checks in both eras and fails if any of them don't match. You can run it from docs/guides-examples/build-mcp-server-go/.

5. Connect it to an AI app​

Connecting a local server is the same process as for any other stdio server. The reference guide covers the Claude Desktop config file in detail. For this Go server the command is the built binary, and there are no args:

claude_desktop_config.json
{
"mcpServers": {
"bookmarks": {
"command": "/ABSOLUTE/PATH/TO/bookmarks/build/bookmarks"
}
}
}

Replace the path with the full path to build/bookmarks in your project. On Windows, write the path with double backslashes or forward slashes. Save the file and quit and reopen the app. The server writes to ~/.bookmarks-mcp.json unless you start it with BOOKMARKS_FILE set.

Common problems​

could not parse /path/to/file: unexpected end of JSON input The bookmarks file exists but is empty. A tool such as mktemp creates an empty file, and the server can't read it as JSON. Delete the file, or point BOOKMARKS_FILE at a new path. The first successful save writes a valid file.

url must be a full http or https address, including https:// The URL you passed is missing its scheme or host, for example modelcontextprotocol.io instead of https://modelcontextprotocol.io. The server returns this as a tool error with isError set to true, so the model sees it and can retry with the full URL.

The server starts and then disconnects straight away. Something printed to standard output. Look for fmt.Print, fmt.Printf and fmt.Println calls, and switch them to log.

The build fails with a Go version error. Install Go 1.25 or later. The SDK's go.mod requires 1.25.0.

FAQ​

Is the Go SDK official?

Yes. The Go SDK is hosted in the modelcontextprotocol GitHub organization and describes itself as the official Go software development kit for MCP. This guide uses version 1.8.0.

Does this Go server work with older AI apps?

Yes. The SDK answers both the 2026-07-28 revision and apps that use the older initialize handshake. The test script checks both protocol eras, so you don't write separate code for each.

Why build an MCP server in Go instead of Python or TypeScript?

Go compiles to a single program that you can start without installing a runtime alongside it. The tools and the protocol are the same as the Python and TypeScript versions, so you can pick the language your team already uses.

Does the Go SDK support remote servers?

The SDK includes a Streamable HTTP handler for servers that run over the network. This guide uses stdio, which runs the server on your own computer.