λ akhil
← Back to Blog

hamr — building MCP servers in Go (the struct tags are the schema)

I got tired of writing JSON Schema by hand every time I built an MCP server. So I wrote a Go framework where your struct tags literally are your schema. Zero reflection at runtime. Here's how it works.

gomcpopen-source

I've been building MCP (Model Context Protocol) servers for a while now. And the thing that kept pissing me off was the boilerplate. Every tool definition needed a JSON Schema. Every schema needed manual maintenance. And God forbid you forget to update it when your struct changes.

So I built hamr.

the idea

The whole thing fits in one sentence: your Go struct tags are your schema. Full stop.

type AddTool struct {
    A int `json:"a" description:"First number"`
    B int `json:"b" description:"Second number"`
}

func (a AddTool) Run(ctx context.Context, req AddRequest) (int, error) {
    return req.A + req.B, nil
}

That's it. description tag generates the schema description. json tag maps the field name. Types are inferred. No separate schema file. No code generation step. No reflect at runtime.

why Go?

Honestly? Because I wanted something that compiled to a single binary and didn't need a VM. MCP servers are infrastructure — they should be small, fast, and easy to deploy. Go gives you:

  • Actual concurrency (goroutines, not async/await callbacks)
  • Compiles to a static binary (scratch deploy, no dependencies)
  • Context built in (cancellation, deadlines, tracing — all first class)
  • The stdlib is genuinely good (I barely need dependencies)

The server starts in under 5ms. The binary is 8MB. You can deploy it to a $5 VPS and it'll handle hundreds of concurrent connections.

how it actually works

Under the hood, hamr uses go:generate to create type-safe client stubs at build time. The struct tags are parsed once during code generation, and the JSON Schema is embedded directly in the binary. At runtime, it's just function calls — no reflection, no serialization overhead, no surprises.

The architecture is minimal:

Tool struct → go:generate → Schema + Stubs → Embedded in binary → Serve

Each tool has three parts:

  1. Input struct — what the LLM sends
  2. Run method — what happens
  3. Output — what the LLM gets back

That's it. No interfaces to implement beyond the one Run method. No lifecycle hooks. No middleware spaghetti. Just the thing.

what I'd do differently

I'll be honest — the go:generate approach works but it's not as seamless as I'd like. You need to run go generate ./... after changing structs. I'm considering a hamr new CLI tool that watches your files and regenerates automatically.

Also, error handling is verbose right now. Every tool returns (T, error) and I'm debating whether a result type would be cleaner. Maybe.

what's next

  • Auth middleware — API keys and JWT out of the box
  • OpenTelemetry — every tool call should be traceable
  • WebSocket transport — for persistent connections
  • hamr new CLI scaffolder — hamr new tool AddTool

The repo is public. PRs welcome. Go stars appreciated. https://github.com/AKhilRaghav0/hamr

AKhil Raghav — iOS/OSX Swift Developer