I Built an MCP Server in 200 Lines of Go (and Claude Became 10x More Useful) Developer built Godex, an AI coding agent, and created an MCP server in 200 lines of Go, arguing that Go is the ideal language for MCP servers due to its standard library JSON-RPC support, concurrency, single-binary deployment, and growing community adoption. The server integrates with Claude Desktop, enabling tools like read_file and run_go, and the author claims it made Claude 10x more useful. I spent two months building Godex , an AI coding agent that lives in your terminal. It works. It ships. People are using it. And in the process, I built something quietly powerful that I didn’t realize I’d built: an MCP server. I’ll admit — I was wrong about MCP. When Anthropic first shipped the spec, I read the docs, nodded, and went back to building Godex the way I was building Godex: a CLI that acted as a tool dispatcher I’d written by hand. I thought MCP was “the standard for connecting Claude to your tools.” That’s true, but it’s the boring reading of it. The interesting reading is this: MCP is the API contract for the AI era, and Go is the language the rest of the world is going to need to learn to write it in. Let me show you what I mean by building one from scratch in 200 lines. By the end of this article, you’ll have a working MCP server with two tools read file and run go , wired into Claude Desktop, ready to extend. The whole thing fits in one file. Here’s how I explain MCP to people who haven’t built one yet. Imagine Claude is a friend visiting a new city. You hand them a single laminated card with three things on it: the restaurant name , the menu items , and the rules for ordering . The friend can now order food, but only what’s on the menu, and only by following the rules. MCP is that card. It’s a standardized JSON-RPC contract that says: That’s it. The LLM agent is the customer. Your server is the kitchen. The protocol is the waiter’s notepad. This is why the spec is so small about 30 pages and why implementing it takes a weekend, not a quarter. The hard part was never the protocol — it was deciding what tools to expose and how to describe them well enough that the model picks the right one . The lunch menu metaphor also explains why MCP is winning. Before MCP, every AI tool integration was bespoke: a LangChain agent here, a Vercel function there, a custom OpenAI function schema over there. Every tool was a snowflake. MCP says: pick from the menu. The model does the rest. There are four reasons I think Go is going to win the MCP server niche. I’ll defend each in one sentence, then we’ll move on to the code. 1. Standard library JSON-RPC is one import. MCP speaks JSON-RPC 2.0 over stdio or HTTP. Go’s encoding/json plus a 30-line request dispatcher gives you the whole transport. In Python, you’re pulling in three libraries and praying they don’t drift. In TypeScript, you’re configuring a bundler. In Go, you ship a binary. 2. Goroutines make tool calls concurrent for free. When Claude asks “read these 5 files,” you don’t want to do it sequentially. Go’s goroutines + a sync.WaitGroup or a buffered channel means you fan out, collect, and return — in maybe 8 lines. 3. Single-binary deployment to Claude Desktop is one file. Claude Desktop’s MCP config points at an executable. Go produces one binary. No venv, no node modules, no version conflicts. Your user double-clicks the .exe, it works. 4. The Go dev community is shipping MCP servers faster than anyone else. GitHub’s official github-mcp-server is Go. mcp-grafana is Go. Most of the top-starred MCP servers I see on GitHub trending are Go. This is a place where we already have home-field advantage. If you’re a Go developer reading this in 2026, you’re early. The bar to ship a useful MCP server is shockingly low, and the demand curve is vertical. Here’s the entire shape of an MCP server, expressed as a Go interface. We’re going to teach this contract before we implement it, because once you see it, you can build any tool you can imagine. type Tool interface { Name string Description string Schema map string any Run ctx context.Context, args map string any any, error } Four methods. That’s the whole abstraction. Let me explain what each is for, because the protocol literally just shuffles these. Everything else in the server is plumbing: route the request, look up the tool by name, call Run , wrap the result back in JSON-RPC, send it back over stdio. Maybe 150 lines of plumbing. The interesting part is the tool. Here is the complete, working MCP server. I’ve called it mcp-server-go and the full repo is at the end of the article. Read it top-to-bottom once; we’ll walk through the parts that matter next. package mainimport "bufio" "context" "encoding/json" "fmt" "io" "log" "os" "os/exec" "path/filepath" "strings" "sync" // ---------- Tool contract ----------type Tool interface { Name string Description string Schema map string any Run ctx context.Context, args map string any any, error }// ---------- ReadFileTool ----------type ReadFileTool struct { BaseDir string}func t ReadFileTool Name string { return "read file" }func t ReadFileTool Description string { return "Read the contents of a text file. " + "Use this when the user asks about a file in the project. " + "Path is relative to the project root."}func t ReadFileTool Schema map string any { return map string any{ "type": "object", "properties": map string any{ "path": map string any{ "type": "string", "description": "Relative path to the file e.g. 'main.go' ", }, "max bytes": map string any{ "type": "integer", "description": "Maximum bytes to read default 50000 ", }, }, "required": string{"path"}, }}func t ReadFileTool Run ctx context.Context, args map string any any, error { path, := args "path" . string if path == "" { return nil, fmt.Errorf "path is required" } maxBytes := 50000 if mb, ok := args "max bytes" . float64 ; ok { maxBytes = int mb } full := filepath.Join t.BaseDir, path cleaned := filepath.Clean full if strings.HasPrefix cleaned, t.BaseDir { return nil, fmt.Errorf "path escapes base directory" } f, err := os.Open cleaned if err = nil { return nil, err } defer f.Close limited := io.LimitReader f, int64 maxBytes data, err := io.ReadAll limited if err = nil { return nil, err } return string data , nil}// ---------- RunGoTool ----------type RunGoTool struct { BaseDir string Timeout int // seconds}func t RunGoTool Name string { return "run go" }func t RunGoTool Description string { return "Run a Go command e.g. 'go test ./...', 'go build .' in the project. " + "Returns combined stdout/stderr. Use this for builds, tests, vet, and module commands."}func t RunGoTool Schema map string any { return map string any{ "type": "object", "properties": map string any{ "args": map string any{ "type": "array", "items": map string any{"type": "string"}, "description": "Arguments to pass to 'go' e.g. 'test', './...' ", }, }, "required": string{"args"}, }}func t RunGoTool Run ctx context.Context, args map string any any, error { rawArgs, := args "args" . any if len rawArgs == 0 { return nil, fmt.Errorf "args is required" } goArgs := make string, len rawArgs for i, a := range rawArgs { goArgs i , = a. string } timeout := t.Timeout if timeout == 0 { timeout = 30 } c, cancel := context.WithTimeout ctx, time.Duration timeout time.Second defer cancel cmd := exec.CommandContext c, "go", goArgs... cmd.Dir = t.BaseDir out, err := cmd.CombinedOutput if err = nil { return map string any{ "output": string out , "error": err.Error , }, nil } return map string any{"output": string out }, nil}// ---------- JSON-RPC plumbing ----------type Request struct { JSONRPC string json:"jsonrpc" ID any json:"id,omitempty" Method string json:"method" Params json.RawMessage json:"params,omitempty" }type Response struct { JSONRPC string json:"jsonrpc" ID any json:"id,omitempty" Result any json:"result,omitempty" Error Error json:"error,omitempty" }type Error struct { Code int json:"code" Message string json:"message" }// ---------- Server ----------type Server struct { tools map string Tool mu sync.RWMutex}func NewServer Server { return &Server{tools: make map string Tool }}func s Server Register t Tool { s.mu.Lock defer s.mu.Unlock s.tools t.Name = t}func s Server handle ctx context.Context, req Request Response { switch req.Method { case "initialize": return Response{JSONRPC: "2.0", ID: req.ID, Result: map string any{ "protocolVersion": "2024-11-05", "serverInfo": map string any{"name": "mcp-server-go", "version": "0.1.0"}, "capabilities": map string any{"tools": map string any{}}, }} case "tools/list": s.mu.RLock defer s.mu.RUnlock list := make any, 0, len s.tools for , t := range s.tools { list = append list, map string any{ "name": t.Name , "description": t.Description , "inputSchema": t.Schema , } } return Response{JSONRPC: "2.0", ID: req.ID, Result: map string any{"tools": list}} case "tools/call": var p struct { Name string json:"name" Arguments map string any json:"arguments" } if err := json.Unmarshal req.Params, &p ; err = nil { return Response{JSONRPC: "2.0", ID: req.ID, Error: &Error{Code: -32602, Message: err.Error }} } s.mu.RLock t, ok := s.tools p.Name s.mu.RUnlock if ok { return Response{JSONRPC: "2.0", ID: req.ID, Error: &Error{Code: -32601, Message: "tool not found"}} } result, err := t.Run ctx, p.Arguments if err = nil { return Response{JSONRPC: "2.0", ID: req.ID, Error: &Error{Code: -32000, Message: err.Error }} } return Response{JSONRPC: "2.0", ID: req.ID, Result: map string any{ "content": map string any{{"type": "text", "text": fmt.Sprintf "%v", result }}, }} default: return Response{JSONRPC: "2.0", ID: req.ID, Error: &Error{Code: -32601, Message: "method not found"}} }}// ---------- main: stdio loop ----------func main { dir, := os.Getwd srv := NewServer srv.Register &ReadFileTool{BaseDir: dir} srv.Register &RunGoTool{BaseDir: dir, Timeout: 60} in := bufio.NewReader os.Stdin for { line, err := in.ReadBytes '\n' if err = nil { return } var req Request if err := json.Unmarshal line, &req ; err = nil { log.Printf "bad json: %v", err continue } resp := srv.handle context.Background , req if req.ID = nil { out, := json.Marshal resp fmt.Fprintln os.Stdout, string out } }} That’s it. ~200 lines including comments. The whole server is one file. It compiles to one binary. The two tools it exposes — read file and run go — are useful enough that you can ask Claude “read main.go and tell me what could be wrong” and it actually does the work. Let me walk through the four sections in plain English. As I said above, four methods. Name, Description, Schema, Run. This is the entire contract. If you can implement these four methods for something — anything — you can wrap it as an MCP tool. A Postgres query? A Redis GET? A Slack channel read? All just Run ctx, args → result. The interface is the abstraction. ReadFileTool is intentionally boring. It opens a file, reads up to max bytes, returns the string. The one non-obvious thing: it does a filepath.Clean check to make sure the requested path doesn’t escape the base directory. This is the kind of thing that, if you skip it, turns your friendly MCP server into a directory traversal exploit. Always sandbox tool paths to a known root. RunGoTool wraps exec.CommandContext with a timeout. Returns combined stdout+stderr on both success and failure, because Claude wants to see the test failures, not just a boolean. The 30-second default timeout is the secret sauce: long enough for go test ./..., short enough that a stuck command doesn’t hang Claude Desktop forever. The Request and Response types are verbatim JSON-RPC 2.0. I’m not doing anything clever here. The protocol’s value is precisely that you don’t have to. main reads one line at a time from stdin, dispatches, and writes one line to stdout. This is the stdio transport , which is what Claude Desktop uses. If you want HTTP, swap the loop for an http.Handler. The server doesn’t care. The if req.ID = nil check matters: initialize and tools/list are requests, but notifications/initialized is a notification no ID . Notifications don’t get replies. Easy bug to introduce. This is the part where your server becomes a thing Claude can actually use. Claude Desktop reads a claude desktop config.json file location varies by OS and starts each registered server as a subprocess. You point it at your binary. On macOS the config lives at: ~/Library/Application Support/Claude/claude desktop config.json On Windows: %APPDATA%\Claude\claude desktop config.json Add this: { "mcpServers": { "mcp-server-go": { "command": "/absolute/path/to/your/mcp-server-go", "args": } }} Restart Claude Desktop. Open a new conversation. In the input box you’ll see a small 🔌 icon — click it. You’ll see read file and run go listed. Now you can say: “Read the main.go in my project and run go vet on it. Tell me what could be wrong.” Claude will: You just built a coding agent. It took 200 lines and 15 minutes. I built three iterations of this server before I shipped the one above. Here’s what I wish I’d known on day one. 1. Skimping on Description . My first read file description was just “Reads a file.” Claude didn’t know when to call it. I changed it to a three-sentence description that said when and why , and the model started calling it 10x more. Write tool descriptions like the system prompt for a junior developer. They are, in a sense, the system prompt for the model. 2. Forgetting to return errors as data, not exceptions. MCP clients expect isError: true in the result envelope, not a thrown error. The protocol treats errors as content. I was wrapping errors in Error{} and losing the stdout. Once I started returning {output: ..., error: ...} as a result, Claude could see the actual test failure messages. 3. No timeout on exec. A 5-minute go test ./... hung Claude Desktop. I had to hard-kill the app. The 30-second default in RunGoTool is non-negotiable. Every tool that does I/O needs a timeout. Every one. 4. Trusting args blindly. I had a path argument that I passed straight to os.Open without validation. A prompt-injection attack could have read /etc/passwd. The filepath.Clean + HasPrefix check is the difference between a demo and a deployable tool. Treat all tool inputs as untrusted. 5. Forgetting the notifications/initialized no-op. The MCP spec says the client sends a notifications/initialized message after initialize. My server was trying to reply to it and getting stuck. Adding the if req.ID = nil skip fixed it. Read the spec once, end to end. It takes 30 minutes and saves a day. You now have a working server with two tools. The hard part is done. Here are the four most useful things you could add next, in order of how much they’ll teach you. 1. A Postgres query tool. Same pattern. Take a SQL string or a parameterized query + args , run it with database/sql, return the rows as JSON. You’ll learn how to handle streaming results, big result sets, and dangerous queries. The pg query go library gives you a query parser if you want to whitelist which statements are allowed. 2. A run command tool with an allowlist. Generalize run go to run any shell command, but with a strict allowlist of binaries. The security model is the whole point. You’ll learn process supervision, signal handling, and how to write a tool that doesn’t become a backdoor. 3. A search code tool ripgrep-backed . Wrap rg with a JSON output flag, return matches as a structured result. This is the tool Claude uses constantly in Anthropic’s own internal workflows, and it’s missing from 90% of MCP servers I’ve seen. You’ll learn how to chunk large results and how to filter noise. 4. A fetch url tool with a domain allowlist. Wrap net/http, fetch the page, return the markdown. Same security model as 2 — only certain domains allowed. This is the gateway to “Claude can read my docs / blog / changelog” workflows, and it’s how most teams’ first useful MCP server is built. Each of these is 50–100 lines on top of the framework above. Pick one and ship it this weekend. The compounding effect of having a real, working tool is bigger than you think. MCP isn’t a feature. It’s a protocol . And protocols don’t get replaced; they get built on. HTTP is 35 years old and still here. JSON-RPC is 20 years old and still here. The companies that won those protocol wars were the ones who shipped implementations while everyone else was still arguing about specs. We are in the same window right now with MCP. The spec is stable. The clients Claude Desktop, Cursor, Zed, Codex are real and growing. The tool ecosystem is sparse. And the language best positioned to write MCP servers in — Go — is also the language with the deepest tradition of getting network protocols right. If you’re a Go developer who has been wondering what to build in 2026, here is your answer: build the MCP server for a tool you use every day. Wrap your team’s internal API. Wrap your favorite CLI. Wrap your database. Wrap the thing you’ve been copy-pasting from Stack Overflow for three years. The lunch menu is open. You’re the kitchen. The customer is hungry. I Built an MCP Server in 200 Lines of Go and Claude Became 10x More Useful https://blog.devgenius.io/i-built-an-mcp-server-in-200-lines-of-go-and-claude-became-10x-more-useful-5352546a48cb was originally published in Dev Genius https://blog.devgenius.io on Medium, where people are continuing the conversation by highlighting and responding to this story.