#Building an MCP Server in Go

Elliot Forbes · Jun 7, 2026 · 7 min read

In this tutorial, we’re going to build a Model Context Protocol (MCP) server in Go from scratch. MCP is the standard that lets AI assistants like Claude call out to your own code — your APIs, your databases, your search indexes — in a consistent way. By the end you’ll have a small but complete server that exposes a search_tutorials tool, handles tool calls, and serves over stdio so any MCP client can connect to it.

If you prefer video, I’ve covered this exact material on YouTube:

Prerequisites

Before we get started, you’ll need:

  • Go 1.23 or later
  • Basic familiarity with Go modules, structs, and functions
  • An MCP client to test against (optional) — Claude Desktop works well, but we’ll also test the server directly from the terminal

What Is the Model Context Protocol?

The Model Context Protocol is an open standard that defines how an AI application (the client) talks to an external program (the server) that provides extra capabilities. Instead of every tool inventing its own integration, MCP gives you one protocol for exposing tools (functions the model can call), resources (data the model can read), and prompts.

A server advertises what it can do during a handshake, and the client — often an LLM-powered app — decides when to call your tools based on the descriptions you give them. That last point matters: the quality of your tool descriptions directly affects whether the model uses them correctly.

Setting Up the Project

Create a new directory and initialise a Go module:

mkdir mcp-server-go && cd mcp-server-go
go mod init github.com/tutorialedge/mcp-server-go

Then add the mcp-go library, which is the most widely used Go implementation of the protocol:

go get github.com/mark3labs/mcp-go

We’re using mark3labs/mcp-go because it handles the JSON-RPC plumbing, the handshake, and the transport layer for us — we only have to describe our tools and write the handlers. Building MCP’s wire protocol by hand is possible, but there’s no reason to when a well-maintained library exists.

Creating the MCP Server

Every MCP server starts with a server instance. Create a main.go and add:

package main

import (
	"context"
	"fmt"
	"strings"

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

func main() {
	s := server.NewMCPServer(
		"tutorialedge",
		"1.0.0",
	)
	// ... we'll add the tool next
}

The name and version you pass to NewMCPServer are reported back to the client during the initialisation handshake. Clients use this to identify your server, so give it a meaningful name rather than something generic.

Defining a Tool

A tool is a function the model can call, described by a name, a human-readable description, and a typed parameter schema. Here we define search_tutorials, which takes a single required query string:

tool := mcp.NewTool("search_tutorials",
	mcp.WithDescription("Search TutorialEdge content for tutorials matching a query"),
	mcp.WithString("query",
		mcp.Required(),
		mcp.Description(`The search term, e.g. "go agents"`),
	),
)

The WithDescription and Description strings aren’t just documentation — they’re sent to the model as part of the tool schema and are how it decides when to call the tool and what to pass. Treat them like prompt engineering: be explicit, and include an example value where it helps. mcp.Required() marks the parameter as mandatory, which means the library will reject calls that omit it before your handler ever runs.

Implementing the Tool Handler

A handler has a fixed signature: it receives a context.Context and a CallToolRequest, and returns a *CallToolResult. Ours reads the query argument, runs a search, and formats the matches:

func handleSearch(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
	query, err := request.RequireString("query")
	if err != nil {
		return mcp.NewToolResultError(err.Error()), nil
	}

	matches := search(query)
	if len(matches) == 0 {
		return mcp.NewToolResultText(fmt.Sprintf("No tutorials found for %q.", query)), nil
	}

	var b strings.Builder
	fmt.Fprintf(&b, "Found %d tutorial(s) for %q:\n\n", len(matches), query)
	for _, t := range matches {
		fmt.Fprintf(&b, "- %s\n  %s\n", t.Title, t.URL)
	}

	return mcp.NewToolResultText(b.String()), nil
}

The important detail here is the difference between the two kinds of failure. When RequireString fails, we return mcp.NewToolResultError(...) with a nil error. That’s deliberate: a malformed argument is a tool error that we want to hand back to the model so it can correct the call and try again. If we returned a non-nil Go error instead, that would signal a transport-level failure and break the connection. Reserve the returned error for genuine infrastructure problems.

For the search itself, we use a tiny in-memory catalog. In a real server you’d query a database or a search API in its place:

type tutorial struct {
	Title string
	URL   string
	Tags  []string
}

var catalog = []tutorial{
	{
		Title: "Getting Started with the Claude API in Go",
		URL:   "https://tutorialedge.net/ai/getting-started-with-claude-api-in-go/",
		Tags:  []string{"ai", "go", "anthropic"},
	},
	{
		Title: "Building AI Agents in Go",
		URL:   "https://tutorialedge.net/ai/building-ai-agents-in-go/",
		Tags:  []string{"ai", "go", "agents"},
	},
	// ...more entries
}

func search(query string) []tutorial {
	q := strings.ToLower(query)
	var out []tutorial
	for _, t := range catalog {
		if strings.Contains(strings.ToLower(t.Title), q) {
			out = append(out, t)
			continue
		}
		for _, tag := range t.Tags {
			if strings.Contains(strings.ToLower(tag), q) {
				out = append(out, t)
				break
			}
		}
	}
	return out
}

Keeping the search logic in its own function — separate from the MCP handler — means you can unit test it without standing up a server, and swap the implementation later without touching the protocol code.

Registering the Tool and Serving over stdio

Finally, register the tool with its handler and start serving:

s.AddTool(tool, handleSearch)

if err := server.ServeStdio(s); err != nil {
	fmt.Printf("server error: %v\n", err)
}

ServeStdio runs the server over standard input and output. This is the most common transport for local MCP servers because it needs no ports or networking — the client launches your binary as a subprocess and talks to it over its stdin/stdout pipes. The library also supports HTTP/SSE transports if you need to run the server as a remote service.

Testing the Server

You don’t need a full LLM to check the server works. MCP is just JSON-RPC over stdio, so you can drive it from the terminal. Build the binary and pipe in an initialize request followed by a tools/call:

go build -o te-mcp .

printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"1.0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_tutorials","arguments":{"query":"agents"}}}' \
| ./te-mcp

You’ll see the tool result come back with the matching tutorial. Once that works, you can point a real client at it. In Claude Desktop, add the server to your claude_desktop_config.json:

{
  "mcpServers": {
    "tutorialedge": {
      "command": "/absolute/path/to/mcp-server-go/te-mcp"
    }
  }
}

Restart Claude Desktop and your search_tutorials tool will be available in conversations. The manual terminal test is the fastest feedback loop while developing; the Claude Desktop config is how you actually use the server day to day.

Frequently Asked Questions

What is the Model Context Protocol (MCP)?

MCP is an open standard that defines how AI applications connect to external tools and data sources. A server exposes capabilities — tools, resources, and prompts — and a client like Claude discovers and calls them through a single, consistent protocol, so you don’t have to build a bespoke integration for every assistant.

What library should I use to build an MCP server in Go?

github.com/mark3labs/mcp-go is the most popular and actively maintained Go implementation. It handles the JSON-RPC messaging, the initialisation handshake, and the stdio/HTTP transports, leaving you to define tools and write handlers.

How does an MCP client know when to call my tool?

The client sends your tool’s name, description, and parameter schema to the model. The model decides whether a tool is relevant based on those descriptions, so clear, specific wording in WithDescription and Description directly improves how reliably your tool gets used.

Why does the server communicate over stdio?

stdio is the simplest transport for local servers — the client launches your binary as a subprocess and exchanges JSON-RPC messages over its standard input and output, with no ports or networking to configure. For remote or shared servers, mcp-go also supports HTTP and Server-Sent Events transports.

What’s the difference between returning a tool error and a Go error from a handler?

Return mcp.NewToolResultError(...) with a nil error for problems the model can recover from, like an invalid argument — this is sent back as a tool result so the model can retry. Return a non-nil Go error only for genuine transport or infrastructure failures, as that signals something the protocol itself can’t recover from.

What’s Next