Skip to content

Filesystem Server ​

The filesystem server demonstrates how to build a read-only MCP server that exposes local files through both tools and resources.

Overview ​

This example demonstrates:

  • Read files by absolute path with read_file
  • List directory contents with list_dir
  • Static resource for project README
  • Resource template for arbitrary file paths

Full Source Code ​

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

pub fn main(init: std.process.Init) void {
    run(init.io, init.gpa) catch |err| mcp.reportError(err);
}

fn run(io: std.Io, allocator: std.mem.Allocator) !void {
    var schema_arena = std.heap.ArenaAllocator.init(allocator);
    defer schema_arena.deinit();
    const sa = schema_arena.allocator();

    const read_schema = try buildReadSchema(sa);
    const list_schema = try buildListSchema(sa);

    var server = mcp.Server.init(allocator, .{
        .name = "filesystem-server",
        .version = "1.0.0",
        .title = "Filesystem Server",
        .description = "Read files and list directories on the local filesystem",
        .instructions =
        \\Use read_file to read a text file at any absolute path.
        \\Use list_dir to list the contents of a directory.
        \\Access specific files as resources at file://<absolute-path>.
        ,
    });
    defer server.deinit();

    try server.addTool(.{
        .name = "read_file",
        .description = "Read the text content of a file at the given path",
        .title = "Read File",
        .inputSchema = read_schema,
        .annotations = .{ .readOnlyHint = true, .idempotentHint = true },
        .handler = readFileHandler,
    });

    try server.addTool(.{
        .name = "list_dir",
        .description = "List files and directories at the given path",
        .title = "List Directory",
        .inputSchema = list_schema,
        .annotations = .{ .readOnlyHint = true, .idempotentHint = true },
        .handler = listDirHandler,
    });

    try server.addResource(.{
        .uri = "file:///README.md",
        .name = "README",
        .description = "Project readme file",
        .mimeType = "text/markdown",
        .handler = readmeHandler,
    });

    try server.addResourceTemplate(.{
        .uriTemplate = "file://{+path}",
        .name = "local-file",
        .title = "Local File",
        .description = "Access any local file by its absolute path",
        .mimeType = "text/plain",
    });

    server.enableLogging();
    try server.run(io, allocator, .stdio);
}

fn buildReadSchema(allocator: std.mem.Allocator) !mcp.types.InputSchema {
    var b = mcp.schema.InputSchemaBuilder.init(allocator);
    defer b.deinit(allocator);
    _ = b.setSchemaDialect("https://json-schema.org/draft/2020-12/schema");
    _ = try b.addString(allocator, "path", "Absolute path to the file", true);
    return b.toInputSchema(allocator);
}

fn buildListSchema(allocator: std.mem.Allocator) !mcp.types.InputSchema {
    var b = mcp.schema.InputSchemaBuilder.init(allocator);
    defer b.deinit(allocator);
    _ = b.setSchemaDialect("https://json-schema.org/draft/2020-12/schema");
    _ = try b.addString(allocator, "path", "Absolute path to the directory", true);
    return b.toInputSchema(allocator);
}

fn readFileHandler(_: ?*anyopaque, io: std.Io, allocator: std.mem.Allocator, args: ?std.json.Value) mcp.tools.ToolError!mcp.tools.ToolResult {
    const path = mcp.tools.getString(args, "path") orelse
        return mcp.tools.errorResult(allocator, "Missing argument: path") catch return mcp.tools.ToolError.OutOfMemory;

    const file = std.Io.Dir.openFileAbsolute(io, path, .{}) catch |err| {
        const msg = std.fmt.allocPrint(allocator, "Cannot open '{s}': {s}", .{ path, @errorName(err) }) catch
            return mcp.tools.ToolError.OutOfMemory;
        return mcp.tools.errorResult(allocator, msg) catch return mcp.tools.ToolError.OutOfMemory;
    };
    defer file.close(io);

    var reader_buf: [4096]u8 = undefined;
    var file_reader = file.reader(io, &reader_buf);
    const content = file_reader.interface.allocRemaining(allocator, std.Io.Limit.limited(1024 * 1024)) catch |err| {
        const msg = std.fmt.allocPrint(allocator, "Cannot read '{s}': {s}", .{ path, @errorName(err) }) catch
            return mcp.tools.ToolError.OutOfMemory;
        return mcp.tools.errorResult(allocator, msg) catch return mcp.tools.ToolError.OutOfMemory;
    };

    return mcp.tools.textResult(allocator, content) catch return mcp.tools.ToolError.OutOfMemory;
}

fn listDirHandler(_: ?*anyopaque, io: std.Io, allocator: std.mem.Allocator, args: ?std.json.Value) mcp.tools.ToolError!mcp.tools.ToolResult {
    const path = mcp.tools.getString(args, "path") orelse
        return mcp.tools.errorResult(allocator, "Missing argument: path") catch return mcp.tools.ToolError.OutOfMemory;

    var dir = std.Io.Dir.openDirAbsolute(io, path, .{ .iterate = true }) catch |err| {
        const msg = std.fmt.allocPrint(allocator, "Cannot open dir '{s}': {s}", .{ path, @errorName(err) }) catch
            return mcp.tools.ToolError.OutOfMemory;
        return mcp.tools.errorResult(allocator, msg) catch return mcp.tools.ToolError.OutOfMemory;
    };
    defer dir.close(io);

    var buf: std.ArrayList(u8) = .empty;
    defer buf.deinit(allocator);

    var iter = dir.iterate();
    while (iter.next(io) catch null) |entry| {
        const kind: []const u8 = switch (entry.kind) {
            .directory => "[dir]  ",
            .file => "[file] ",
            else => "[other]",
        };
        if (std.fmt.allocPrint(allocator, "{s} {s}\n", .{ kind, entry.name })) |str| {
            buf.appendSlice(allocator, str) catch {};
            allocator.free(str);
        } else |_| {}
    }

    const listing = buf.toOwnedSlice(allocator) catch return mcp.tools.ToolError.OutOfMemory;
    return mcp.tools.textResult(allocator, listing) catch return mcp.tools.ToolError.OutOfMemory;
}

fn readmeHandler(_: ?*anyopaque, _: std.Io, _: std.mem.Allocator, uri: []const u8) mcp.resources.ResourceError!mcp.resources.ResourceContent {
    return .{
        .uri = uri,
        .mimeType = "text/markdown",
        .text =
        \\# Filesystem Server
        \\
        \\An MCP server providing read-only filesystem access.
        \\
        \\## Tools
        \\- `read_file(path)` — read a text file
        \\- `list_dir(path)` — list directory contents
        ,
    };
}

Build and Run ​

bash
zig build
./zig-out/bin/filesystem-server

PowerShell (Windows):

powershell
zig build
.\zig-out\bin\filesystem-server.exe

Client Usage ​

Read a File ​

bash
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"/etc/hostname"}}}' | ./zig-out/bin/filesystem-server

PowerShell:

powershell
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"C:\\Users\\user\\Downloads\\mcp.zig\\src\\version.zig"}}}' | .\zig-out\bin\filesystem-server.exe

List a Directory ​

bash
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_dir","arguments":{"path":"/tmp"}}}' | ./zig-out/bin/filesystem-server

Expected Output ​

read_file response:

json
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"/// Current version of the MCP library\npub const version = \"0.0.6\";\n"}],"isError":false,"resultType":"complete","structuredContent":{"text":"/// Current version of the MCP library\npub const version = \"0.0.6\";\n"}}}

list_dir response:

json
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"[dir]  src\n[dir]  docs\n[file] build.zig\n[file] README.md\n"}],"isError":false,"resultType":"complete","structuredContent":{"text":"[dir]  src\n[dir]  docs\n[file] build.zig\n[file] README.md\n"}}}

Security Note ​

This example grants full filesystem read access. In production, restrict paths by validating against allowed root directories before calling std.Io.Dir.openFileAbsolute.

Next Steps ​