a Model Context Protocol library for zig, server and client

Files
README.md

mcp #

a Model Context Protocol library for zig, server and client, that implements the specification as written and nothing more. no tool registry, no schema generation, no decorators: those belong in fastmcp, which builds on this.

  • speaks only the stateless 2026-07-28 revision (server/discover, per-request _meta, resultType, caching hints, multi round-trip requests, standard and custom Mcp-* headers, subscriptions/listen). nothing the revision deprecates is here: no initialize, sessions, sampling, roots, logging, HTTP+SSE or dynamic client registration. an initialize gets -32022 naming the version it should use
  • transports: stdio over any std.Io.Reader / std.Io.Writer (pipes, TCP, unix sockets, in memory), and streamable HTTP (SSE or JSON per request, host and origin allowlists, mountable beside your own routes)
  • requests run concurrently; cancellation, progress, completions, input and output schema validation, middleware, custom methods and notifications, and a pluggable bus for fanning subscriptions out across replicas
  • extensions: tasks (SEP-2663), skills (SEP-2640), MCP Apps
  • a client (mcp.Client) over stdio subprocesses, any byte stream or HTTP: typed helpers with auto-pagination, timeouts and cancellation, per-call progress, a response cache that honors ttlMs/cacheScope, result and elicitation validation, subscriptions/listen, and zero round-trip reconnects from a saved discover result
  • OAuth 2.1 on both sides. client (mcp.oauth): discovery, client ID metadata documents, PKCE, scope step-up, a loopback browser flow, token storage and refresh, and the auth extensions (client credentials with a secret or private_key_jwt, enterprise-managed authorization, workload identity, DPoP). server (mcp.resource_server): bearer verification, protected resource metadata, per-tool scopes, 401/403 step-up
  • passes the official conformance suite, server and client, every extension scenario included. the only baselined checks are for features removed on purpose
  • the python, typescript, go and rust sdks' examples are ported to examples/ports as self-checking programs (zig build examples)
  • interoperates with the typescript, go and rust sdks, client and server, over HTTP and stdio, extensions included (interop, interop/run.sh)
  • std only, no dependencies. zig 0.17.0

usage #

const std = @import("std");
const mcp = @import("mcp");

const Handler = struct {
    pub fn listTools(_: *Handler, _: *mcp.Request, _: mcp.schema.ListParams) !mcp.schema.ListToolsResult {
        return .{ .tools = &.{.{
            .name = "echo",
            .inputSchema = .{ .raw =
            \\{ "type": "object", "properties": { "text": { "type": "string" } } }
            },
        }} };
    }

    pub fn callTool(_: *Handler, req: *mcp.Request, params: mcp.schema.CallToolParams) !mcp.schema.CallToolResult {
        if (!std.mem.eql(u8, params.name, "echo")) return req.reject(.invalid_params, "Unknown tool");
        const Args = struct { text: []const u8 };
        const args = std.json.parseFromValueLeaky(Args, req.arena, params.arguments orelse .null, .{}) catch |err| switch (err) {
            error.OutOfMemory => |e| return e, // not the client's fault
            else => return req.reject(.invalid_params, "expected {\"text\": string}"),
        };
        return .{ .content = try req.arena.dupe(mcp.schema.Content, &.{.fromText(args.text)}) };
    }
};

pub fn main(init: std.process.Init) !void {
    var handler: Handler = .{};
    var server: mcp.Server(Handler) = try .init(init.io, init.gpa, &handler, .{
        .info = .{ .name = "echo", .version = "0.1.0" },
    });

    var in_buf: [64 * 1024]u8 = undefined;
    var out_buf: [64 * 1024]u8 = undefined;
    var stdin = std.Io.File.stdin().readerStreaming(init.io, &in_buf);
    var stdout = std.Io.File.stdout().writerStreaming(init.io, &out_buf);
    try mcp.stdio.serve(&server, init.io, init.gpa, &stdin.interface, &stdout.interface, .{});
}

over HTTP instead:

    try mcp.http.serve(&server, init.io, init.gpa, .{ .port = 8000 });

decoding arguments by hand like this is the price of the slim layer; fastmcp derives it from a function signature.

Server(Handler) reads the handler's pub declarations at compile time and advertises a capability for each complete pair: listTools + callTool, listResources + readResource (+ optional listResourceTemplates), listPrompts + getPrompt. callTool, readResource and getPrompt may return mcp.schema.Outcome(T) to ask the client for input (elicitation) through a multi round-trip request. see examples/server.zig for all three primitives, and examples/everything.zig for everything the conformance suite exercises.

the client side is mcp.Client(Handler), where the handler answers what the server asks for (elicit). it mirrors tool arguments into headers and runs multi round-trip requests to completion. examples/conformance_client.zig drives it through every client scenario, auth included.

add it to a project:

zig fetch --save git+https://tangled.org/zzstoatzz.io/mcp
exe.root_module.addImport("mcp", b.dependency("mcp", .{}).module("mcp"));

ownership and failures #

  • everything about one message (its bytes, the parsed tree, the Request, decoded params) lives in a per-message arena. handlers allocate results from req.arena; the stdio loop resets it after the response is flushed. anything that must outlive the message needs an owner the handler controls.
  • req.reject(code, message) is a JSON-RPC error the client sees. a tool that ran and failed returns a result with isError = true instead, so the model can see it.
  • any other handler error becomes -32603 Internal error and is logged to stderr. running out of memory produces the same error, written without allocating. all allocation happens before the first response byte, so stdout never carries a partial message; an allocation-failure sweep in src/tests.zig holds that invariant.

performance #

examples/bench.zig sends sequential tools/call requests over HTTP with this library's client. against mcp-everything (ReleaseFast) and the python sdk's everything-server, same machine, one client:

server revision req/s p50 p99 rss
zig 2026-07-28 11,900 73 µs 238 µs 1.7 MB
python 2026-07-28 570 1.6 ms 3.7 ms 82 MB

(measured before the legacy era was removed; not rerun since.)

zig build -Doptimize=ReleaseFast
./zig-out/bin/mcp-everything --port 3001 &
./zig-out/bin/mcp-bench http://127.0.0.1:3001/mcp 5000 2026-07-28

not yet #

  • a tool registry, typed arguments and the rest of the DX: that is fastmcp
  • websockets (not in the spec; any byte stream works with stdio.serve)

development #

zig build test --summary all   # unit tests
zig build examples             # the ported sdk examples, each checking itself
zig build example              # run the example server on stdio
zig build conformance          # drive the example through the python sdk (needs uv)
zig build conformance-suite    # the official conformance suite (needs node)

conformance runs scripts/conformance.py against the reference python sdk, pinned to mcp==2.3.0. conformance-suite runs scripts/conformance-suite.sh, which pins @modelcontextprotocol/conformance@0.2.0-alpha.12: the server suite against mcp-everything and the client suite with mcp-conformance-client at 2026-07-28, extensions included. it fails on any FAILURE or WARNING (the suite's own summary hides warnings), and on any check in scripts/conformance-baseline.txt (removed features) that unexpectedly passes.