mcp-zig: A 131 KB MCP Server Template in Zig
How I built a minimal MCP server template that compiles to a 131 KB binary - 397x smaller than the TypeScript SDK's node_modules - with zero dependencies, a comptime tool registry, and a built-in client.
Rach Pradhan
Design Engineer
Hey everyone, Rach here.
If you read my last post about building nanobrew, you know I've been deep in Zig lately. What started as "can I make Homebrew faster?" turned into building a full package manager - and somewhere along the way, Zig rewired how I think about systems software. Comptime, zero-copy everything, arena allocators, 1.2 MB binaries that replace 57 MB Ruby runtimes. Once you see what's possible when you strip away all the runtime overhead, it's hard to go back.
So when I kept spinning up MCP servers for Claude Code and watching node_modules balloon to 52 MB every time, the question was obvious: what if I just wrote this in Zig too?
MCP (Model Context Protocol) is how tools like Claude Code talk to external programs - JSON-RPC 2.0 over stdio. You write a server, register some tools, and suddenly Claude can read your files, query your databases, or do whatever you wire up. The official SDKs work fine. But why does a process that reads stdin and writes stdout need 52 MB of dependencies?
So I built mcp-zig. The whole thing compiles to 131 KB. And I built it in a weekend using a mix of Claude Opus 4.6 and Codex 5.3 - plus a custom assistant I built on top of EmergentDB. More on that later.
GitHub: github.com/justrach/mcp-zig
The Size Problem
MCP servers are long-lived stdio processes. They sit in the background, waiting for Claude Code to call their tools. Binary size matters for distribution. Startup latency matters for developer experience. Runtime dependencies matter for reproducibility.
Here's how the official SDKs compare for a minimal server with two tools:
| SDK | Language | Distributable Size |
|---|---|---|
| typescript-sdk | TypeScript | ~52 MB node_modules + Node.js |
| python-sdk | Python | ~50+ MB site-packages + Python |
| csharp-sdk | C# NativeAOT | ~8–15 MB binary |
| go-sdk | Go | ~5–8 MB binary |
| rust-sdk | Rust | ~2–4 MB binary |
| mcp-zig | Zig | 131 KB binary |
zig installation needed to run the output. You ship one file.
131 KB. That's 397x smaller than the TypeScript SDK's distribution. And the binary has everything - server, protocol handling, JSON parsing, tool dispatch.
What Even Is MCP?
MCP over stdio is newline-delimited JSON-RPC 2.0. One JSON object per line, no Content-Length headers (unlike LSP). The lifecycle is simple:
Claude sends JSON-RPC requests to stdin, your server writes responses to stdout. That's it. The protocol is minimal, but every official SDK wraps it in layers of abstraction - async runtimes, schema validators, dependency injection frameworks.
I wanted to see how thin the implementation could actually be.
The Architecture
The entire server is 6 files:
src/
main.zig - entry point (21 lines)
mcp.zig - protocol loop, JSON-RPC dispatch (195 lines)
tools.zig - tool definitions and handlers (125 lines)
json.zig - line reader, field extraction, escaping (79 lines)
client.zig - MCP client library (188 lines)
registry.zig - comptime tool registry (152 lines)
build.zig - build config (41 lines)
The entry point is 5 lines of actual logic:
pub fn main() void {
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{};
defer _ = gpa.deinit();
mcp.run(gpa.allocator());
}
The protocol loop in mcp.zig reads lines from stdin, parses JSON-RPC, and dispatches. The critical invariant: every write to stdout is exactly one JSON object followed by \n. Break this and Claude Code's ReadBuffer will parse each line as a separate (invalid) message and kill your server.
\\ multiline string literals embed literal newlines. Without stripping, those newlines break the line-delimited protocol. The writeResult function strips \n and \r from result strings before writing. I found this the hard way.
Adding a Tool - 4 Steps
This was the main design goal. Adding a tool to your MCP server should be brain-dead simple. Here's the process:
extern "C", Go via cgo), and your handlers can always shell out to external processes. The client library (client.zig) can also talk to MCP servers written in any language over stdio. But the tool handlers themselves? Zig. That's the tradeoff for 131 KB.
1. Add to the enum:
pub const Tool = enum {
read_file,
list_dir,
my_new_tool, // add here
};
2. Add the JSON Schema (this is what Claude reads to understand your tool):
pub const tools_list =
\\{"tools":[
\\...,
\\{"name":"my_new_tool","description":"Does something useful.",
\\"inputSchema":{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}}
\\]}
;
3. Add a dispatch branch:
pub fn dispatch(...) void {
switch (tool) {
.read_file => handleReadFile(alloc, args, out),
.list_dir => handleListDir(alloc, args, out),
.my_new_tool => handleMyNewTool(alloc, args, out),
}
}
4. Write the handler:
fn handleMyNewTool(
alloc: std.mem.Allocator,
args: *const std.json.ObjectMap,
out: *std.ArrayList(u8),
) void {
const input = json.getStr(args, "input") orelse {
out.appendSlice(alloc, "error: missing 'input'") catch {};
return;
};
out.appendSlice(alloc, input) catch {};
}
Whatever you write to out becomes the tool response shown to Claude. Errors go to out too - never panic.
Tool dispatch is a comptime switch. std.meta.stringToEnum generates a perfect hash at compile time. Zero runtime overhead for tool name lookup. No string comparison chains, no hash maps.
The Comptime Registry
Four steps is already pretty minimal. But I wanted to see if I could get it down to one. That's what registry.zig does.
Instead of manually maintaining the enum, the dispatch switch, and the JSON list, you define everything in one place:
const my_tools = registry.Registry(&.{
.{ .name = "read_file", .handler = handleReadFile, .schema = read_file_schema },
.{ .name = "list_dir", .handler = handleListDir, .schema = list_dir_schema },
});
The Registry type generates parse(), dispatch(), and tools_list at comptime. All of it. The compiler does the work - zero runtime cost.
But the real trick is wrapFn. It takes a normal Zig function and wraps it into an MCP handler:
fn greet(name: []const u8) []const u8 {
return name;
}
const handler = registry.wrapFn(greet, &.{"name"});
At compile time, wrapFn inspects the function signature, generates JSON parameter extraction based on argument types ([]const u8 → getStr, i64 → getInt, bool → getBool), and wraps it all in the handler interface. You write a normal function. The compiler writes the MCP boilerplate.
@typeInfo at comptime gives you full access to function signatures - parameter types, return type, everything. wrapFn uses this to generate a handler that extracts each parameter from the JSON args by name, calls your function, and writes the result to out. Error unions get caught and their error names written as error messages. It's 50 lines of comptime metaprogramming that eliminates all the boilerplate.
The Client Side
mcp-zig isn't just a server template. It includes a full MCP client library for calling any MCP server programmatically from Zig.
var client = try McpClient.init(alloc, &.{"/path/to/server"}, null);
defer client.deinit();
const init_result = try client.initialize();
defer alloc.free(init_result);
try client.notifyInitialized();
const result = try client.callTool("read_file", "{\"path\":\"hello.txt\"}");
defer alloc.free(result);
Or the one-shot convenience:
const result = try callOnce(alloc, &.{"/path/to/server"}, "read_file", "{\"path\":\"hello.txt\"}");
Spawn → initialize → call tool → return result → clean up, in one function call. The client spawns the server as a child process, communicates via stdin/stdout pipes, and handles the full JSON-RPC lifecycle.
This means you can build MCP-to-MCP pipelines entirely in Zig. One server's output feeds another server's input. No shell scripts, no Node.js glue.
Why Zig for This?
I talked about this in the nanobrew post, but it bears repeating in this context because the same properties that made Zig perfect for a package manager made it even more perfect for MCP servers.
When I was building nanobrew, I fell in love with a few things: comptime generating SIMD routines at compile time, arena allocators keeping the hot path allocation-free, and the whole thing compiling to a 1.2 MB binary that replaced a 57 MB Ruby runtime. Those weren't just nice-to-haves. They were the reason nanobrew was 500x faster than Homebrew.
And MCP servers are an even better fit for Zig than package managers. Here's why:
MCP over stdio is fundamentally a synchronous, line-at-a-time protocol. You read a line, parse it, dispatch, write a response. There's no concurrency. There's no event loop. There's no reason for an async runtime.
Zig's comptime is perfect for this. Tool schemas are string literals - they exist in the binary as static data, no runtime construction. Tool dispatch is a comptime switch - perfect hash, zero overhead. The wrapFn registry generates parameter extraction at compile time based on function signatures.
And because there's no runtime, no GC, no reflection metadata, the binary is tiny. 131 KB for a fully functional MCP server with two example tools, a JSON parser, a protocol handler, and a client library.
Building This with AI (All of Them)
Okay, I have to talk about the elephant in the room. I built an MCP server template for Claude Code using... well, a whole stack of AI tools. And the journey of how I built it ended up being almost as interesting as the template itself.
With nanobrew, I wrote most of the Zig by hand - I was learning the language as I went. By the time I started mcp-zig, I had the architecture in my head (comptime dispatch, arena allocators, zero-copy patterns) and I wanted to move fast. So I reached for everything.
I used a mixture of Claude Opus 4.6 and Codex 5.3, bouncing between them depending on the task. But what really changed the game was building my own Serena-like assistant on top of EmergentDB. Think of it as a custom coding agent that understands my codebase context - not just the current file, but the architecture decisions, the patterns I care about, the mistakes I've already made. It's like having a pair programmer who actually read the docs.
Here's what each tool was good at:
registry.zig) - the wrapFn with @typeInfo introspection, the ArgsTuple parameter extraction, the error union handling - that's 50 lines of comptime magic that I probably would have spent a day getting right manually. There's a reason it's eating enterprise AI spend.Codex 5.3 was great for the more mechanical parts - scaffolding the JSON-RPC protocol loop, generating the client library's lifecycle management, getting the edge cases right (notifications without
id, string vs integer id).My EmergentDB assistant tied it all together. Because it had context on my architecture decisions from nanobrew, it could suggest patterns that were consistent with how I think about systems code. When I said "I need a tool dispatch", it didn't just generate a switch statement - it suggested the comptime approach because it knew that's the pattern I used in nanobrew.
The client library was a good example of the workflow. I'd describe the architecture to my EmergentDB assistant, it would outline the approach drawing on patterns from nanobrew, then I'd have Opus or Codex generate the implementation - McpClient with the __ID__ template replacement trick, the one-shot callOnce convenience wrapper, proper cleanup in deinit.
There's something poetic about using AI to build tools that make AI better. Every MCP server I build with mcp-zig gives Claude Code new capabilities. And a whole stack of AI tools helped me build the template. It's turtles all the way down.
The best AI coding workflow I've found: know the architecture you want (from experience, from building things by hand), then use the right AI for each subtask. No single model does everything best. Opus 4.6 for the hard comptime stuff, Codex 5.3 for the mechanical scaffolding, and a custom context-aware assistant to keep it all coherent. mcp-zig went from idea to working template in a weekend.
Protocol Details That Bit Me
A few things I learned the hard way about implementing MCP:
Notifications have no id. When Claude sends notifications/initialized, there's no id field. If you try to respond, you'll confuse the client. Check for id before writing a response.
The id can be a string or integer. JSON-RPC says id can be string, number, or null. Claude Code uses integers, but other clients might use strings. The appendId function handles both.
Newlines in tool output kill the server. This bears repeating. If your tool reads a file and returns it as-is, any \n in the content splits the JSON-RPC response across multiple lines. Claude's parser sees the first line as an incomplete JSON object, fails to parse, and kills your server. The writeEscaped function in json.zig handles this by escaping \n → \\n, \r → \\r, and all control characters below 0x20.
The tools_list response is not wrapped. When Claude calls tools/list, you return the raw JSON object with a tools array. But when Claude calls tools/call, you wrap the result in a content envelope: {"content":[{"type":"text","text":"..."}],"isError":false}. Getting this wrong means Claude either can't see your tools or can't read your results.
What I Took Away
Most MCP servers are wildly overengineered for what the protocol actually requires. MCP over stdio is one JSON object per line, one request at a time. You don't need an async runtime. You don't need a dependency injection framework. You don't need 52 MB of node_modules. You need a line reader, a JSON parser, and a switch statement.
Comptime metaprogramming eliminates boilerplate without runtime cost. The wrapFn registry lets you write normal functions and have the compiler generate all the MCP plumbing. No macros, no code generation step, no separate schema files.
And distributable size matters more than people think for developer tools. An MCP server that's 131 KB can live in a dotfiles repo. One that's 52 MB needs its own installation step.
Frequently Asked Questions
Do I need Zig installed to run the server?
No. You need Zig to build the server, but the output is a single static binary with no dependencies. Copy it anywhere and run it. No runtime required.
Does it work with Claude Code?
Yes. Add it to ~/.claude.json under mcpServers with the path to the binary. Restart Claude Code and your tools appear as mcp__my-server__read_file, etc. I use it daily.
Can it handle large responses?
The default read_file handler caps at 1 MB. You can increase this per-tool. The JSON escaping and line writer handle arbitrarily large responses - they stream to stdout, so memory usage stays bounded.
Why not just use the TypeScript SDK?
If you're already in a Node.js environment, the TypeScript SDK is fine. mcp-zig is for when you want a standalone binary with no dependencies - for distribution, for embedding in other tools, or when you care about startup latency and binary size. It's also a good way to learn how MCP actually works under the hood, since there's no abstraction layers to dig through.
What about the Rust SDK?
Rust is the closest competitor in terms of binary size (2-4 MB). The main differences: Zig's comptime gives you compile-time metaprogramming without proc macros, the binary is 20-35x smaller, and the codebase is ~864 lines vs thousands. If you already know Rust, the Rust SDK is solid. If you want minimal, mcp-zig is minimal.
Is this production-ready?
It's a template, not a framework. The protocol implementation handles the full MCP lifecycle correctly, but it's intentionally minimal - no logging, no metrics, no middleware. Fork it, add your tools, ship it. I've been running MCP servers built on this template for the past couple of days without issues.
Can I write tools in Python/TypeScript/Rust/etc?
Tool handlers are Zig functions - you can't directly plug in code from another language. However, Zig has seamless C ABI interop, so you can call into C libraries, Rust (extern "C"), or Go (cgo). Your handlers can also shell out to any external process and pipe the output back. And the client library (client.zig) can talk to MCP servers written in any language over stdio, so you can build cross-language MCP pipelines.
Try It Out
git clone https://github.com/justrach/mcp-zig.git
cd mcp-zig
zig build -Doptimize=ReleaseSmall
strip zig-out/bin/mcp-zig
# → 131 KB
Register in ~/.claude.json:
{
"mcpServers": {
"my-server": {
"command": "/absolute/path/to/mcp-zig",
"args": []
}
}
}
Restart Claude Code. Edit tools.zig. Add your tools. Ship a 131 KB binary.
Requires Zig 0.15. MIT licensed.
If something breaks, open an issue. Would love to hear what you build with it.
Until next time,
Rach