#Building an MCP Server in Go
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
- Add more tools to the server — for example, one that fetches the full content of a tutorial by URL.
- Read Building AI Agents in Go to see how an LLM orchestrates tool calls like the one you just built.
- Brand new to calling models from Go? Start with Getting Started with the Claude API in Go.
Continue Learning
Getting Started with the Claude API in Go
Learn how to use the Anthropic Claude API in Go — set up the SDK, send your first message, and build a multi-turn conversation that maintains context.
Building AI Agents in Go
Learn how to build AI agents in Go that can use tools, make decisions, and complete tasks autonomously.
Building AI Applications with LangChainGo
Learn how to build AI-powered applications in Go using the LangChainGo library with practical examples.
Building RAG Applications in Go
Learn how to build Retrieval Augmented Generation (RAG) applications in Go using LangChainGo and Ollama.