I decided to build an MCP server in Go, and for the first hour I was very smug about it.
The Model Context Protocol sounds simple on paper. Its whole pitch is "USB-C for AI." Plug any AI assistant into any data source through a standard interface, and everyone stops writing bespoke integrations. Anthropic dropped it in late 2024, and by 2025 even OpenAI and Google were on board. It's an open standard, not a vendor lock-in play. Great. Love it. Sign me up.
Then I actually tried to implement one.
What I Thought I Was Getting Into
If you've read the MCP spec (and I assume you haven't, because who reads specs for fun), the pitch is one protocol to rule them all. Your AI assistant can read files, query databases, call APIs, send emails, whatever, as long as the thing on the other end speaks MCP. It's supposed to solve the NxM integration problem where every AI tool needs custom connectors for every data source.
The architecture splits into four layers:
- Host: the app you're actually using (chat client, IDE, whatever)
- Client: a connector inside the host, one per server session
- Server: the thing you build, exposing tools/resources/prompts
- Data Source: the actual database, API, or file system behind it
It's the Language Server Protocol for AI. LSP lets any editor support any language through one protocol. MCP lets any AI assistant support any data source through one protocol. The analogy is almost too tidy, but it holds up surprisingly well once you're past the initialization dance.
The Initialization Dance (Not the Fun Kind)
MCP runs on JSON-RPC 2.0. If you've never worked with JSON-RPC, imagine REST but every response has to include a correlating ID and the method names look like URL paths written by someone who really loves dots. Things like tools/call and resources/list.
The session starts with a handshake:
- Client sends an
initializerequest proposing a protocol version and its capabilities. - Server responds with its own version and capabilities.
- Client sends an
initializednotification. - Now they talk.
The handshake is where I tripped on my own feet the first time. The capability negotiation isn't optional fluff. If you forget to advertise a capability during init, the client will never call that method, even if your server handles it perfectly. I spent 45 minutes wondering why tools/call requests weren't coming through, and it was because my server hadn't told the client it supported tools.
The rule: capabilities are a contract. Your initialize response is your word. Don't lie about it, and don't forget to mention things.
STDIO vs HTTP
MCP supports two main transports, and your choice says something about you as an engineer.
STDIO is the simple path. Your server runs as a subprocess, reads JSON from stdin, writes to stdout. This is what local MCP servers do, and it's what I started with because it's one less thing to debug. The host spawns your binary, pipes messages in, reads responses out. Done.
HTTP with Server-Sent Events is the grown-up path. The server runs as a persistent service, clients POST requests to a single endpoint, and the server streams responses or pushes notifications through SSE. This is what you want for a remote server that multiple clients might hit.
I started with STDIO because I am lazy and wanted to see a working ping-pong as fast as possible. This was the right call. The go-sdk makes STDIO trivially easy:
s.Run(ctx, &mcp.StdioTransport{})
That's it. Your server now speaks MCP over standard pipes. The HTTP transport, by contrast, involves wiring up an HTTP handler, managing SSE streams, and dealing with CORS headers. Not hard, but not one line either.
I'll switch to HTTP when I need to. For now, STDIO keeps the surface area small.
The SDK Situation (A Brief Soapbox)
The official MCP Go SDK (github.com/modelcontextprotocol/go-sdk) exists now, maintained with Google's Go team. It hit v1.0.0 on 30 September 2025 and has been shipping regular minor releases since. Before it existed, the community had two solid libraries:
- mark3labs/mcp-go by Ed Zynda: widely used, influenced the official SDK's design
- mcp-golang by Metoro: used in early tutorials, different API shape
I used the official SDK because I like living on main and reporting bugs. If you're building something that needs to ship today, either community library is fine. The patterns are similar either way: create a server, register handlers, run a transport loop.
The official SDK gives you a mcp package with server and client constructors, a jsonschema helper for tool parameter schemas, and transport implementations. The pre-1.0 releases moved fast enough that I hit a breaking change between two versions during a single weekend. Since v1.0 it follows semver, so that particular pain is behind us, and the ergonomics are good.
What I Actually Built: A Filesystem Tool Server
My MCP server exposes two tools: one that reads files and one that searches their contents. I picked this because it's useful and easy to test, and it let me exercise Resources and Tools in the same codebase without getting ambitious.
The skeleton looks like this (simplified, but the actual shape):
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type ReadInput struct {
Path string `json:"path" jsonschema:"absolute path to the file"`
}
type SearchInput struct {
Directory string `json:"directory" jsonschema:"root directory to search"`
Pattern string `json:"pattern" jsonschema:"search term (case-insensitive)"`
}
func main() {
s := mcp.NewServer(&mcp.Implementation{
Name: "file-helper",
Version: "0.1.0",
}, nil)
// Register a tool
mcp.AddTool(s, &mcp.Tool{
Name: "read_file",
Description: "Read contents of a file at the given path",
}, handleReadFile)
mcp.AddTool(s, &mcp.Tool{
Name: "search_files",
Description: "Recursively search files in a directory for a string",
}, handleSearchFiles)
ctx := context.Background()
if err := s.Run(ctx, &mcp.StdioTransport{}); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
os.Exit(1)
}
}
func handleReadFile(ctx context.Context, req *mcp.CallToolRequest, in ReadInput) (*mcp.CallToolResult, any, error) {
data, err := os.ReadFile(filepath.Clean(in.Path))
if err != nil {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}},
IsError: true,
}, nil, nil
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: string(data)}},
}, nil, nil
}
func handleSearchFiles(ctx context.Context, req *mcp.CallToolRequest, in SearchInput) (*mcp.CallToolResult, any, error) {
pattern := strings.ToLower(in.Pattern)
var results []string
filepath.WalkDir(in.Directory, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return nil // skip unreadable
}
if strings.Contains(strings.ToLower(string(data)), pattern) {
results = append(results, path)
}
return nil
})
text := fmt.Sprintf("Found matches in %d files:\n%s", len(results), strings.Join(results, "\n"))
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: text}},
}, nil, nil
}
The pattern is: describe an mcp.Tool with a name and description, define a Go struct for its inputs, and hand both to mcp.AddTool. The SDK derives the JSON Schema from your struct and wires the handler into the JSON-RPC machinery. tools/list auto-discovers your registered tools. tools/call routes to the right handler and decodes the arguments into your input struct before your code runs.
I still spent more time on the input schemas than I want to admit. Having AddTool generate the schema from the Go type removes a whole category of hand-written JSON, and struct tags cover the common cases. But the moment you need custom validators or nested objects you're back in the jsonschema package doing it by hand, and JSON Schema has a special kind of pain where something that looks like it should be simple turns into 45 minutes of required fields not nesting correctly.
The Things That Tripped Me Up
1. Notifications Have No IDs and It Bites You
JSON-RPC notifications are fire-and-forget. No ID, no response. This is fine for event announcements (resource updated, logging, progress). But I accidentally sent a notification format when I meant to send a request, and then spent an hour adding debug logging because my handler was running but nobody was getting results.
The debug logging showed the handler ran fine. The handler returned a result. The result went nowhere, because there was no correlating request ID for the client to match. Notifications aren't requests, so they don't get responses. They just disappear into the void. Which is fine if you're saying "hey, a file changed." It's not fine if you're saying "here's your search results."
2. Tool Error Handling Is Weird
Tool calls return either a success result or an error object, but there's also an isError flag on the result payload. This means you can return a valid CallToolResult with no error code but set isError: true, and the client will treat it as a failure. This tripped me up because I was returning generic errors from my handler instead of setting IsError on the result.
The official way to signal a tool-level failure is:
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "could not read file: permission denied"}},
IsError: true,
}, nil, nil
This returns a properly structured error result that the client can display to the user or pass back to the LLM. Returning an actual Go error from the handler function, on the other hand, signals a protocol-level problem. The distinction matters: tool errors mean "the tool ran but couldn't complete its task," while handler errors mean "something is broken in the server."
3. Resource URIs Are Weirder Than They Look
Resources use URIs with custom schemes like file:///path/to/doc.txt or docs://section/installation. The scheme is yours to define, and the spec encourages semantic naming. But I learned that the client will try to interpret file:// URIs as actual file paths in some implementations, and this can cause confusion if your server uses file:// schemas for things that aren't local files.
Use a custom scheme. Call it toolbox:// or mine:// or whatever. Keep file:// for actual filesystem paths.
4. The Initialization Timeout Will Catch You
If your server takes more than a few seconds to start up (because it's loading models, connecting to databases, whatever), the client may time out during the handshake. The SDK itself does not impose a startup deadline, but clients do, and theirs are often surprisingly short. I hit this when testing against the MCP Inspector, and it just showed a connection error with no details.
The fix: either do your heavy initialization before calling Run(), or configure a longer timeout on the client side. The server doesn't control the timeout, so you can only work around it by being fast at startup.
5. Context Propagation Is Your Friend
MCP sessions can include long-running operations like file searches or API calls. The context.Context passed to your handler carries cancellation signals from the client. If a user cancels an operation, the client sends a CancelledNotification, and the server should abort the context.
I initially ignored the context in my handleSearchFiles function. Then I tried searching a directory with 50,000 files and couldn't cancel it. The function just walked the entire tree before returning, and the client sat there waiting. Once I wired the context into filepath.WalkDir (by using a custom callback that checks ctx.Err()), cancellations worked immediately.
Where MCP Works and Where It Grates
The good parts:
- Standardizing tool definitions so any MCP client can discover and call them.
- The capability negotiation upfront so both sides know what's available.
- Resource subscriptions for reactive updates (file changes, new data, etc.).
- The growing ecosystem. There are pre-built servers for PostgreSQL, GitHub, Slack, Google Drive, Puppeteer, Google Maps, and more. You can grab one and wire it into Claude or Cursor immediately.
The parts that made me swear:
- The spec is still evolving. Version strings like "2025-06-18" aren't semver, and breaking changes happen between what would be minor bumps in a saner system.
- Tool input schemas are JSON Schema, which is powerful but painful to write and debug by hand. You end up with deeply nested validation rules that you can't lint.
- STDIO transport assumes the host manages the server process lifecycle. If your host crashes, the server process can become orphaned. The spec doesn't define a heartbeat mechanism, so you get a dangling process that nobody tells to shut down.
- The HTTP transport is underdocumented compared to STDIO. SSE streaming works, but the setup requires more moving parts and the error handling is less mature.
Testing Without an AI Client
You don't need Claude or Cursor to test your MCP server. The MCP Inspector is a web-based GUI that connects to your server (via STDIO or HTTP), lets you send arbitrary JSON-RPC messages, and shows the responses. It's perfect for debugging because you can see the raw protocol messages.
There's also mcp-cli, a command-line tool for the same purpose. I used it to verify my tools/list response before ever pointing a real AI client at the server.
My testing workflow looked like:
- Build the server binary.
- Run the MCP Inspector pointing at the binary.
- Send a
tools/listrequest to confirm tools show up with correct schemas. - Call each tool with valid inputs.
- Call each tool with invalid inputs to verify error responses.
- Test cancellation by starting a long operation and hitting cancel.
Step 6 was the most satisfying because every previous attempt before I wired context propagation would hang. When it finally worked, the operation cancelled instantly and I felt a tiny surge of power.
Worth the Trouble?
If you have a data source your AI assistant needs to talk to, and no existing MCP server handles it, building one is maybe half a day of work with the Go SDK. The protocol overhead is minimal once you get past the initialization negotiation. Most of your code will be actual business logic rather than protocol wiring.
The Go SDK handles the rest. JSON-RPC message framing, capability negotiation, transport I/O, request routing. You write handlers, register them, and run.
I can't promise you won't hit the same footguns I did. The initialization timeout got me, and the capability negotiation, and the notification-vs-request confusion. But once those were behind me, the server just worked. The protocol is solid. The SDK is getting there.
Building an MCP server in Go made me feel like I was building infrastructure, not glue code. That's a good feeling. Even if the first hour was mostly furious Googling.