← writing

Your first MCP server in 80 lines of Go

A practical walkthrough of building a tiny MCP server in Go that exposes one tool to list dotfiles in your home directory, wired up to Claude Desktop.

Everybody keeps telling you MCP is the future, and yet most tutorials hand you a 400 line TypeScript project before you've even seen what a tool call looks like. So let's do the opposite. One file, 80 lines of Go, one tool that lists the dotfiles in your home directory, and a Claude Desktop config snippet at the end. That's the whole post.

Fair warning up front: the hardest part of this is not the Go. It's writing the JSON schema for your tool's input args. I will die on this hill. 🙃

Step 1: project setup

Make a directory, init a module, and pull in the official SDK from modelcontextprotocol/go-sdk. Nothing exotic here.

mkdir dotfiles-mcp && cd dotfiles-mcp
go mod init github.com/you/dotfiles-mcp
go get github.com/modelcontextprotocol/go-sdk/mcp@latest
touch main.go

That's it for setup. If you have Go 1.24 or newer you're good (the official SDK tracks Go's two-most-recent-versions support policy, currently Go 1.25). The SDK pulls in a handful of transitive deps but nothing weird, no cgo, no build tags to worry about.

Step 2: define the server and its one tool

An MCP server is just a thing that speaks JSON-RPC over some transport and advertises a list of tools, resources, and prompts. We only care about tools today. Each tool has a name, a description, an input schema (the JSON one, yes that one), and a handler function.

Here's the top of main.go with the imports, the input/output types, and the server constructor.

package main

import (
	"context"
	"os"
	"path/filepath"
	"strings"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

type ListDotfilesArgs struct {
	IncludeDirs bool `json:"include_dirs" jsonschema:"description=Include hidden directories too"`
}

type ListDotfilesResult struct {
	Home  string   `json:"home"`
	Files []string `json:"files"`
}

Two things worth pointing out. First, the struct tags do double duty. json handles serialization, jsonschema tells the SDK how to describe the field to the model. The SDK reflects over your args struct and generates the JSON schema for you, which spares you from hand writing {"type":"object","properties":{...}} nightmares. Second, the result is also a plain struct. The SDK marshals it and ships it back to the client. No ceremony.

Step 3: implement the handler

The handler reads $HOME, walks the top level, and filters anything starting with a dot. If include_dirs is false we skip directories. Standard library only.

func listDotfiles(ctx context.Context, req *mcp.CallToolRequest, args ListDotfilesArgs) (*mcp.CallToolResult, ListDotfilesResult, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return nil, ListDotfilesResult{}, err
	}
	entries, err := os.ReadDir(home)
	if err != nil {
		return nil, ListDotfilesResult{}, err
	}
	var out []string
	for _, e := range entries {
		name := e.Name()
		if !strings.HasPrefix(name, ".") {
			continue
		}
		if e.IsDir() && !args.IncludeDirs {
			continue
		}
		out = append(out, filepath.Join(home, name))
	}
	return nil, ListDotfilesResult{Home: home, Files: out}, nil
}

The signature looks a bit busy but it's mechanical. First return is a low level CallToolResult if you want to control the raw response (we don't, so nil). Second is your typed result. Third is the error, which the SDK converts into a proper JSON-RPC error for the client. Idiomatic Go, no surprises.

Step 4: register the tool and serve over stdio

Now we glue it together. Build a server, attach the tool with mcp.AddTool, and run it on stdio. Claude Desktop launches your binary as a subprocess and talks to it over stdin and stdout, so stdio transport is exactly what you want.

func main() {
	s := mcp.NewServer(&mcp.Implementation{
		Name:    "dotfiles-mcp",
		Version: "0.1.0",
	}, nil)

	mcp.AddTool(s, &mcp.Tool{
		Name:        "list_dotfiles",
		Description: "List hidden files (and optionally directories) in the user's home directory.",
	}, listDotfiles)

	if err := s.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
		os.Stderr.WriteString("server error: " + err.Error() + "\n")
		os.Exit(1)
	}
}

Count the lines if you don't believe me. Imports, two structs, one handler, one main. It fits comfortably under 80 lines with room for blank lines and a witty comment if you're feeling spicy.

Build it with go build -o dotfiles-mcp . and you've got a binary.

Step 5: wire it up to Claude Desktop

Open your claude_desktop_config.json. On macOS it lives at ~/Library/Application Support/Claude/claude_desktop_config.json. Add an entry under mcpServers pointing at your binary.

{
  "mcpServers": {
    "dotfiles": {
      "command": "/Users/you/code/dotfiles-mcp/dotfiles-mcp",
      "args": []
    }
  }
}

Restart Claude Desktop, open a chat, and ask it to list the dotfiles in your home directory. You'll see the tool call light up in the UI, the args get filled in, and your Go binary respond with the list. The first time it works it feels like magic. The second time you realize you just wrote 80 lines of glue code and the model did the rest.

What's actually happening under the hood

When Claude Desktop boots, it spawns your binary and sends an initialize request over stdio. The SDK responds with your server name, version, and capabilities. Then the client asks for tools/list and gets back the tool definition the SDK generated from your struct tags. When the model decides to call list_dotfiles, the client sends a tools/call request, the SDK unmarshals the args into your ListDotfilesArgs, runs your handler, marshals the result, and ships it back. No magic. Just JSON-RPC with a schema.

Now go build something that does something useful

Listing dotfiles is a toy. The interesting thing is that the exact same shape, one struct in, one struct out, one handler, scales to whatever you want. Query your database. Hit your company's internal API. Drive a build system. Read a vector index. The SDK doesn't care, and neither does the model.

So go build one that does something actually useful. And when you find yourself fighting the JSON schema, remember I warned you. 😉

Want more like this?

Occasional, opinionated, no listicles.
all writing →