Rand Stats

MCP::Server::Tool::FileSystem

zef:apogee

Actions Status

MCP::Server::Tool::FileSystem

A root-confined filesystem toolkit for MCP::Server.

Give a model a directory, not a machine. The pack registers ten tools — read, write, edit, list, glob, grep, stat, mkdir, move and delete — and every path argument they take is interpreted relative to a configured root that no argument can escape.

Synopsis

Plug it into a server you are building by hand:

use MCP::Server;
use MCP::Server::Tool::FileSystem;

my $server = MCP::Server.new(:name<workspace>, :version<1.0>);

$server.plug: MCP::Server::Tool::FileSystem.new(root => '/srv/notes');
# Registers fs_read, fs_write, fs_edit, fs_list, fs_glob, fs_grep,
# fs_stat, fs_mkdir, fs_move, fs_delete

$server.run;   # stdin/stdout

Or name it in a one-shot server, with no use statement at all — the toolkit is loaded at runtime:

use MCP::Server;

MCP::Server.new(
    :name<workspace>,
    :tools[
        'FileSystem' => { root => '/srv/notes', prefix => 'notes' },
        'FileSystem' => { root => '/srv/logs', prefix => 'logs', 'read-only' => True },
    ],
).run;
# notes_read, notes_write, ... and logs_read, logs_list, logs_glob,
# logs_grep, logs_stat

Or skip the Raku entirely and hand raku-mcp a config file:

{
  "name": "workspace",
  "version": "1.0",
  "instructions": "Notes are writable; logs are read-only.",
  "tools": {
    "FileSystem#notes": { "root": "/srv/notes", "prefix": "notes" },
    "FileSystem#logs":  { "root": "/srv/logs", "prefix": "logs", "read-only": true }
  }
}
raku-mcp --config=workspace.json

# Or without a file:
raku-mcp --tool='FileSystem={"root":"/srv/notes"}'

# See what a given configuration would register:
raku-mcp --describe='FileSystem={"root":"/srv/notes"}'

In a Claude Code .mcp.json:

{
  "mcpServers": {
    "notes": {
      "command": "raku-mcp",
      "args": ["--name=notes", "--tool=FileSystem={\"root\":\"/srv/notes\"}"]
    }
  }
}

Configuration

KeyTypeDefaultMeaning
rootIO::PathrequiredDirectory every path is resolved inside. A plain string is coerced. Must exist and be a directory, or construction dies. Stored resolved.
read-onlyBoolFalseWhen True, the write, edit, mkdir, move and delete tools are never registered, and stat reports nothing as writable.

prefix is reserved by the framework rather than the pack: pass it to .plug as :prefix<...>, or put it beside the toolkit's settings in a :tools entry or config file. Passing it to .new or .from-config dies. Without one, the pack's own default prefix fs applies; an explicit empty prefix registers bare names.

Tools

Names below are unprefixed. With the default prefix they are fs_read, fs_write and so on.

Parameter naming

Parameter names in this pack are a contract, not a matter of taste:

The reason is what sits in front of the server. A permission layer — Claude Code's directory rules, or MCP::Client::Policy — decides whether a call may go through by looking at which files it would touch, and it finds them by name. A tool that called its argument filename would sail past a rule written for path. If you write a toolkit of your own that is meant to live behind the same guard, use the same names.

read(path, offset?, limit?)

Slurps a UTF-8 text file. Errors if the path is missing, is a directory, or is not decodable text.

{ "name": "fs_read", "arguments": { "path": "notes/todo.md" } }
=> "- buy milk\n- feed the yak\n"

With neither offset nor limit, what comes back is the file and nothing else: no numbering, no footer, no line-ending translation. That matters — the text is exactly what edit will be matching against later.

Pass offset (1-based) and/or limit to read a window of a long file instead. Ranged output is a view: each line is prefixed with its number and a tab, and a footer says where the window sits.

{ "name": "fs_read", "arguments": { "path": "log.txt", "offset": 40, "limit": 3 } }
=> "40\tconnecting
    41\thandshake ok
    42\tsending 12 frames

    [lines 40-42 of 900 total]"

Strip the number and the tab before handing any of that text to edit as its old-string — the prefixes are not in the file. Windowed lines are split on line endings and rejoined with \n, so a CRLF file's \rs are not in the windowed output either: before building an old-string for a CRLF file, read it without offset/limit — the unranged form is byte-faithful.

Reading past the end is an answer rather than an error, because that is how a model finds out where the end is:

{ "name": "fs_read", "arguments": { "path": "log.txt", "offset": 5000 } }
=> "'log.txt' has 900 lines; offset 5000 is past the end"

An offset or limit below 1 is an error, as is a non-numeric one. A JSON true is refused rather than read as the number 1, which Raku's type system would otherwise let happen quietly.

write(path, content)

Writes UTF-8 text, replacing any existing contents, and confirms with a byte count. The parent directory must already exist — this tool never creates directories implicitly, so a typo in a path cannot quietly scatter new directory trees. Not registered when read-only is set.

{ "name": "fs_write", "arguments": { "path": "notes/todo.md", "content": "- done\n" } }
=> "Wrote 7 bytes to 'notes/todo.md'"

{ "name": "fs_write", "arguments": { "path": "new/todo.md", "content": "x" } }
=> isError: "Parent directory of 'new/todo.md' does not exist; create it with the mkdir tool first"

edit(path, old-string, new-string, replace-all = false)

Replaces one exact run of text and leaves the rest of the file byte for byte as it was. There is no fuzzy matching and no diff format to get wrong: old-string either appears in the file exactly — whitespace, indentation and line endings included — or the call is an error. Not registered when read-only is set.

{ "name": "fs_edit", "arguments": {
    "path": "src/app.raku",
    "old-string": "my $port = 8080;",
    "new-string": "my $port = %*ENV<PORT> // 8080;" } }
=> "Replaced 1 occurrence in 'src/app.raku'; wrote 412 bytes"

The uniqueness rule is the whole safety story, so it is strict:

An empty new-string deletes the match. Line endings elsewhere in the file are never touched — see Security for why that takes deliberate effort in Raku.

list(path = '.')

One entry per line, sorted, directories marked with a trailing / on every platform. An empty directory answers (empty directory) rather than an empty string.

{ "name": "fs_list", "arguments": { "path": "notes" } }
=> "archive/\nideas.md\ntodo.md"

glob(pattern, path = '.')

Walks everything below path and returns matching paths relative to the root, sorted, one per line.

grep(pattern, path = '.', regex = false, glob?, context = 0, max-results = 50)

Searches the files below path — or path itself, if it names a file — and reports matching lines in GNU grep's format, path:line:text, with paths relative to the root.

{ "name": "fs_grep", "arguments": { "pattern": "TODO", "path": "src" } }
=> "src/app.raku:14:# TODO: retries
    src/lib/db.raku:203:# TODO: index this"

By default the pattern is literal text: no escaping, no surprises, . is a full stop. Set regex to true and it becomes a Raku regex, not a PCRE one. Classes, quantifiers, alternation, anchors and captures read the same; what differs is worth knowing before reaching for it:

The rest of the parameters bound what comes back:

grep is a reading tool: it is registered even when read-only is set.

stat(path)

{ "name": "fs_stat", "arguments": { "path": "notes/todo.md" } }
=> "path: notes/todo.md
    type: file
    size: 26
    modified: 2026-07-29T20:14:02Z
    symlink: no
    readable: yes
    writable: yes"

type is file, directory or other; modified is ISO 8601 in UTC; writable is always no on a read-only pack, whatever the mode bits say, because no tool in that configuration can write.

mkdir(path)

Creates a directory and any missing parents. Creating one that already exists is a no-op, not an error, so a model retrying itself does not get punished. Not registered when read-only is set.

{ "name": "fs_mkdir", "arguments": { "path": "notes/archive/2026" } }
=> "Created directory 'notes/archive/2026'"

move(from, to)

Renames a file or directory, or moves it somewhere else inside the root. Both from and to go through the same containment checks, so neither end can leave the sandbox. A directory moves with everything inside it. Not registered when read-only is set.

{ "name": "fs_move", "arguments": { "from": "notes/todo.md", "to": "notes/archive/todo.md" } }
=> "Moved 'notes/todo.md' to 'notes/archive/todo.md'"

Nothing is ever overwritten and nothing is created implicitly:

There is no copy-and-delete fallback across filesystems: the move is a rename, and a rename that the OS refuses (a to on a different mount, say) is reported as the error it is rather than half-performed. See Security.

delete(path, recursive = false)

Deletes a file, or a directory that is empty. Not registered when read-only is set.

{ "name": "fs_delete", "arguments": { "path": "notes/scratch.md" } }
=> "Deleted file 'notes/scratch.md'"

{ "name": "fs_delete", "arguments": { "path": "notes/empty" } }
=> "Deleted empty directory 'notes/empty'"

{ "name": "fs_delete", "arguments": { "path": "notes/archive" } }
=> isError: "Directory 'notes/archive' is not empty; set recursive to true to
   delete it and everything below it"

recursive is rm -rf and nothing less: the directory and every file below it go, with no undo and no confirmation step.

{ "name": "fs_delete", "arguments": { "path": "notes/archive", "recursive": true } }
=> "Deleted directory 'notes/archive' and everything below it"

Symlinks are deleted as the links they are, never followed — a recursive delete of a directory containing a link to somewhere else removes the link and leaves its target alone. Deleting the root itself is refused.

Errors

Handlers throw; the framework turns that into a tool result with isError: true and the message as its text, so the model sees what went wrong and can correct itself. Missing or non-string arguments are reported the same way — the MCP input schema advertises what is required, but nothing in the protocol enforces it.

Security

Confinement is the whole point of this pack, so it is done in layers that each hold on their own. For a path argument $rel:

The textual rejections are deliberately redundant with the physical check: two independent layers fail independently, and they make the failure message say what the model did wrong.

Comparison is exact-case. On a case-insensitive filesystem, a root configured with different casing than the path the OS reports can only produce a false negative — access denied — never a false positive. It fails closed.

Byte fidelity, and why CRLF is a security property

read and edit never see a decoded-and-re-encoded copy of your file: they read bytes and decode them once, and edit writes bytes back.

This is deliberate, and it took work. Raku's IO::Path.slurp in text mode silently folds CRLF into LF, and .spurt does not put it back. An edit that round-tripped through the text path would therefore rewrite the line endings of every line in the file it did not touch — a one-line change arriving as a whole-file diff. On a CRLF checkout that is not a cosmetic problem: it is an invisible, unreviewable change to code, made by a tool the reviewer trusts to change one line.

So the guarantee is: edit changes the bytes you asked it to change and no others, and the text read hands back is what edit will be matching against, byte for byte. The cost is that an old-string copied out of a CRLF file must carry its CRLF; the alternative was silent corruption.

Regex interpolation

grep's regex mode interpolates the client's pattern into a real Raku regex (/<$pattern>/). That is only safe because Rakudo refuses, by default, to interpolate a pattern containing a code block — a pattern like { unlink 'x' } is rejected with Prohibited regex interpolation rather than executed. The refusal comes back to the model as an ordinary tool error.

Never add use MONKEY-SEE-NO-EVAL to this file. That pragma exists to turn exactly this guard off, and turning it off here converts a search tool into remote code execution. The module carries a comment saying so, and t/02-tools.rakutest asserts that an injection pattern comes back as a clean error — that test is the guard on the guard.

Malformed patterns are validated before the walk starts, so a bad pattern costs one clear error rather than a partial traversal.

Residual risks worth knowing:

Testing

prove6 -Ilib t/           # with MCP::Server installed
prove6 -Ilib -I../MCP-Server/lib t/    # against a sibling checkout

t/03-containment.rakutest is the file to read first if you change the resolver: it drives every escape attempt through real tool calls, including symlink escapes, and adapts rather than skips on hosts where creating a symlink is not permitted.

Examples

Author

Matt Doughty

License

Artistic-2.0