{"slug": "i-built-an-mcp-server-in-200-lines-of-go-and-claude-became-10x-more-useful", "title": "I Built an MCP Server in 200 Lines of Go (and Claude Became 10x More Useful)", "summary": "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.", "body_md": "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.\n\nI’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.\n\nThe 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.\n\nBy 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.\n\nHere’s how I explain MCP to people who haven’t built one yet.\n\nImagine 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.\n\nMCP is that card. It’s a standardized JSON-RPC contract that says:\n\nThat’s it. The LLM agent is the customer. Your server is the kitchen. The protocol is the waiter’s notepad.\n\nThis 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**.\n\nThe 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.\n\nThere 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.\n\n**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.\n\n**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.\n\n**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.\n\n**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.\n\nIf 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.\n\nHere’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.\n\n```\ntype Tool interface {    Name() string    Description() string    Schema() map[string]any    Run(ctx context.Context, args map[string]any) (any, error)}\n```\n\nFour methods. That’s the whole abstraction. Let me explain what each is for, because the protocol literally just shuffles these.\n\nEverything 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.\n\nHere 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.\n\n```\npackage 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))  } }}\n```\n\nThat’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.\n\nLet me walk through the four sections in plain English.\n\nAs 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.\n\n**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.**\n\n**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.\n\nThe 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.\n\nmain() 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.\n\nThe 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.\n\nThis 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.\n\nOn macOS the config lives at:\n\n~/Library/Application Support/Claude/claude_desktop_config.json\n\nOn Windows:\n\n%APPDATA%\\Claude\\claude_desktop_config.json\n\nAdd this:\n\n```\n{  \"mcpServers\": {    \"mcp-server-go\": {      \"command\": \"/absolute/path/to/your/mcp-server-go\",      \"args\": []    }  }}\n```\n\nRestart 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:\n\n“Read the main.go in my project and run go vet on it. Tell me what could be wrong.”\n\nClaude will:\n\nYou just built a coding agent. It took 200 lines and 15 minutes.\n\nI built three iterations of this server before I shipped the one above. Here’s what I wish I’d known on day one.\n\n**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.\n\n**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.\n\n**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.\n\n**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.**\n\n**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.\n\nYou 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.\n\n**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.\n\n**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.\n\n**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.\n\n**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.\n\nEach 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.\n\nMCP 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.\n\nWe 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.**\n\nIf 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.\n\nThe lunch menu is open. You’re the kitchen. The customer is hungry.\n\n[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.", "url": "https://wpnews.pro/news/i-built-an-mcp-server-in-200-lines-of-go-and-claude-became-10x-more-useful", "canonical_source": "https://blog.devgenius.io/i-built-an-mcp-server-in-200-lines-of-go-and-claude-became-10x-more-useful-5352546a48cb?source=rss-81ed16b24275------2", "published_at": "2026-06-21 21:01:01+00:00", "updated_at": "2026-06-30 11:23:40.465375+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "large-language-models", "ai-agents"], "entities": ["Godex", "Anthropic", "Claude Desktop", "GitHub", "mcp-grafana", "Go", "JSON-RPC", "MCP"], "alternates": {"html": "https://wpnews.pro/news/i-built-an-mcp-server-in-200-lines-of-go-and-claude-became-10x-more-useful", "markdown": "https://wpnews.pro/news/i-built-an-mcp-server-in-200-lines-of-go-and-claude-became-10x-more-useful.md", "text": "https://wpnews.pro/news/i-built-an-mcp-server-in-200-lines-of-go-and-claude-became-10x-more-useful.txt", "jsonld": "https://wpnews.pro/news/i-built-an-mcp-server-in-200-lines-of-go-and-claude-became-10x-more-useful.jsonld"}}