=begin pod

=head1 MCP::Server::Tool::FileSystem

A root-confined filesystem toolkit for L<MCP::Server|https://github.com/m-doughty/MCP-Server>.

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

=head2 Synopsis

Plug it into a server you are building by hand:

=begin code :lang<raku>
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
=end code

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

=begin code :lang<raku>
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
=end code

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

=begin code :lang<json>
{
  "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 }
  }
}
=end code

=begin code :lang<shell>
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"}'
=end code

In a Claude Code C<.mcp.json>:

=begin code :lang<json>
{
  "mcpServers": {
    "notes": {
      "command": "raku-mcp",
      "args": ["--name=notes", "--tool=FileSystem={\"root\":\"/srv/notes\"}"]
    }
  }
}
=end code

=head2 Configuration

=begin table
Key        | Type     | Default    | Meaning
========== | ======== | ========== | =======
root       | IO::Path | required   | Directory every path is resolved inside. A plain string is coerced. Must exist and be a directory, or construction dies. Stored resolved.
read-only  | Bool     | False      | When True, the write, edit, mkdir, move and delete tools are never registered, and stat reports nothing as writable.
=end table

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

=head2 Tools

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

=head3 Parameter naming

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

=item Every parameter that names a location is called exactly C<path>, or C<from> and C<to> for the one tool that has two.
=item Multi-word parameters are kebab-case: C<old-string>, C<new-string>, C<replace-all>, C<max-results>.

The reason is what sits in front of the server. A permission layer — Claude
Code's directory rules, or L<MCP::Client::Policy|https://github.com/m-doughty/MCP-Client>
— 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 C<filename>
would sail past a rule written for C<path>. If you write a toolkit of your
own that is meant to live behind the same guard, use the same names.

=head3 read(path, offset?, limit?)

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

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

With neither C<offset> nor C<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 C<edit> will be matching against later.

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

=begin code
{ "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]"
=end code

Strip the number and the tab before handing any of that text to C<edit> as
its C<old-string> — the prefixes are not in the file. Windowed lines are
split on line endings and rejoined with C<\n>, so a CRLF file's C<\r>s are
not in the windowed output either: before building an C<old-string> for a
CRLF file, read it without C<offset>/C<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:

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

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

=head3 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 C<read-only> is set.

=begin code
{ "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"
=end code

=head3 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:
C<old-string> either appears in the file exactly — whitespace, indentation
and line endings included — or the call is an error. Not registered when
C<read-only> is set.

=begin code
{ "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"
=end code

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

=item B<Zero matches> is an error. Read the file first; the text you quote must be what is actually there.
=item B<More than one match> is an error too, and the message says how many. Quote more of the surrounding lines until the match is unique, or set C<replace-all> to true and mean it.
=item An empty C<old-string> is refused: it is a position, not a match.
=item An C<old-string> equal to C<new-string> is refused: it would report success for having done nothing.

=begin code
{ "name": "fs_edit", "arguments": {
    "path": "src/app.raku", "old-string": "$port", "new-string": "$listen-port" } }
=> isError: "Found 6 occurrences of 'old-string' in 'src/app.raku'; quote more of
   the surrounding text to pick one out, or set replace-all to true"

{ "name": "fs_edit", "arguments": {
    "path": "src/app.raku", "old-string": "$port", "new-string": "$listen-port",
    "replace-all": true } }
=> "Replaced 6 occurrences in 'src/app.raku'; wrote 431 bytes"
=end code

An empty C<new-string> deletes the match. Line endings elsewhere in the file
are never touched — see L<Security|#security> for why that takes deliberate
effort in Raku.

=head3 list(path = '.')

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

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

=head3 glob(pattern, path = '.')

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

=item C<*> matches any run of characters B<within one path segment>.
=item C<?> matches exactly one such character.
=item Everything else is literal, and matching is case-sensitive.
=item A pattern with no separator is matched against entry names at any depth; a pattern containing C</> is matched against the path relative to the searched directory.
=item Regexes are not accepted — C<.*> means "a literal dot followed by anything", nothing more.
=item Symlinked directories are never descended into, so a glob cannot loop forever or report a file that lives outside the root.

=begin code
{ "name": "fs_glob", "arguments": { "pattern": "*.md" } }
=> "notes/ideas.md\nnotes/todo.md"

{ "name": "fs_glob", "arguments": { "pattern": "notes/*.md" } }
=> "notes/ideas.md\nnotes/todo.md"      # one segment deep only

{ "name": "fs_glob", "arguments": { "pattern": "*.zzz" } }
=> "No matches for '*.zzz' under '.'"
=end code

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

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

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

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

=item Whitespace in the pattern is insignificant. C<foo bar> matches C<foobar>; write C<foo ' ' bar> or C<foo \s bar> for a literal space.
=item C<\d>, C<\w>, C<\s> and C<.> work; C<[abc]> is I<not> a character class in Raku — that is C«<[abc]>», and C<[...]> is a non-capturing group.
=item A pattern containing C<{ }> is refused outright rather than run. See L<Security|#security>.
=item An unparseable pattern is reported as an error before any file is opened, not halfway through the tree.

=begin code
{ "name": "fs_grep", "arguments": { "pattern": "^ sub \\w+", "regex": true, "glob": "*.rakumod" } }
=> "lib/Thing.rakumod:12:sub parse-header(...)
    lib/Thing.rakumod:44:sub emit(...)"

{ "name": "fs_grep", "arguments": { "pattern": "a(b", "regex": true } }
=> isError: "Invalid regex pattern 'a(b': Unable to parse expression ..."
=end code

The rest of the parameters bound what comes back:

=item C<glob> filters by B<file name only>, using the same matcher as the glob tool, at any depth. A C</> in it is refused — filter names with C<glob>, choose where to look with C<path>.
=item C<context> (0 to 20) shows surrounding lines, marked with C<-> instead of C<:> so they cannot be mistaken for matches, and separates non-adjacent groups with C<-->.
=item C<max-results> (1 to 1000, default 50) stops the search after that many I<matching> lines and says so. Files are visited in sorted order, so a truncated result is the same result every time.
=item Files that are not UTF-8 text — a NUL byte in the first 8KB, or bytes that will not decode — are skipped, and a trailing notice counts them.

=begin code
{ "name": "fs_grep", "arguments": { "pattern": "yak", "context": 1 } }
=> "notes/todo.md-3-shopping:
    notes/todo.md:4:- feed the yak
    notes/todo.md-5-- water the yak
    --
    notes/todo.md-11-later:
    notes/todo.md:12:- rename the yak"

{ "name": "fs_grep", "arguments": { "pattern": "the", "max-results": 2 } }
=> "notes/ideas.md:1:the first
    notes/ideas.md:7:the second

    [stopped at 2 matching lines; raise max-results to see more]"
=end code

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

=head3 stat(path)

=begin code
{ "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"
=end code

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

=head3 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 C<read-only> is set.

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

=head3 move(from, to)

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

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

Nothing is ever overwritten and nothing is created implicitly:

=item An existing C<to> — file or directory — is an error. Delete it first if that is really what you meant.
=item A C<to> whose parent directory does not exist is an error, exactly as with C<write>. Use C<mkdir> first.
=item Moving the root itself is refused.

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

=head3 delete(path, recursive = false)

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

=begin code
{ "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"
=end code

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

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

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.

=head2 Errors

Handlers throw; the framework turns that into a tool result with
C<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.

=head2 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 C<$rel>:

=item B<Relative only.> Anything that could be absolute is rejected textually, before any filesystem call: a leading C</> or C<\>, a C<X:> drive prefix, or a C<\\server\share> UNC prefix.
=item B<No C<..> at all.> C<..> segments are refused even when the result would have stayed inside the root (C<a/../b> is an error, not a synonym for C<b>). One rule is easier to audit than an arithmetic one.
=item B<No device names.> Segments Windows resolves to devices regardless of directory — C<CON>, C<PRN>, C<AUX>, C<NUL>, C<COM1>..C<COM9>, C<LPT1>..C<LPT9>, with or without an extension — are refused on every platform, so behaviour is identical everywhere rather than only safe where it is tested.
=item B<No null bytes.>
=item B<Physical-path symlink chase.> The candidate path is built segment by segment under the root; then every symlink in it is chased to its destination — component by component, the way C<realpath> works, with a hop budget against cycles — and the I<physical> result is checked against the root. A symlink planted inside the sandbox therefore cannot be used as a door out of it, whether it points at a file or a directory, whether directly or through a chain, and whether its target is absolute or relative. The chase is the pack's own (over C<readlink>), B<not> C<IO::Path.resolve>, because C<.resolve> does not follow symlinks on Windows — an unfollowed link is an open door the OS walks through at open time even though a check did not. One code path runs on every platform. A link whose target cannot be read, a chain that exceeds the hop budget, and a Windows drive-relative target (C<C:foo>) are all refused outright: fail closed.
=item B<Parent-chain containment, not string prefixes.> The final check walks C<.parent> from the chased path up to the filesystem root looking for the root itself. C</srv/root2> is not inside C</srv/root>, and no separator-spelling difference can fake a match — on Windows both slash spellings are unified before comparing.

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
I<false negative> — access denied — never a false positive. It fails closed.

=head3 Byte fidelity, and why CRLF is a security property

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

This is deliberate, and it took work. Raku's C<IO::Path.slurp> in text mode
silently folds C<CRLF> into C<LF>, and C<.spurt> does not put it back. An
C<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: B<C<edit> changes the bytes you asked it to change and
no others>, and the text C<read> hands back is what C<edit> will be matching
against, byte for byte. The cost is that an C<old-string> copied out of a
CRLF file must carry its CRLF; the alternative was silent corruption.

=head3 Regex interpolation

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

B<Never add C<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
C<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:

=item A symlink already inside the root that points I<within> the root is followed, by design. If you populate the sandbox from an untrusted source, populate it without symlinks.
=item On a platform whose C<resolve> does not fully canonicalise (older Windows), a pre-planted in-root symlink pointing outside could in principle survive the ancestor check. The C<..>/absolute/device rejections still hold; treat the root as a directory you control the contents of.
=item Confinement is not a permission system. Anything the server process can read under the root, the model can read, and on a read-write pack anything it can write, the model can C<delete>. Run it as a user with no more access than you intend to grant, use C<read-only> unless writes are actually wanted, and put a permission layer in front of it if "the whole root" is more than you meant to hand over.
=item C<delete> with C<recursive> is C<rm -rf>, applied to a path the model chose. It refuses the root itself and it unlinks symlinks rather than following them, but within the root it is exactly as final as it sounds. This is the tool to gate behind a permission layer first.
=item C<move> is a rename and only a rename. If the root spans a mount point — a bind mount, a network share grafted into the tree — a move across it fails with the OS's own error instead of silently copying and deleting. Honest, but it means such a move is simply not available; do it with C<read> and C<write> if you must.
=item Nothing here rate-limits or size-limits: C<read> will happily slurp a huge file into memory, C<glob> will walk a huge tree, and C<grep> reads every text file below the path it is given (C<max-results> bounds the I<output>, not the reading). Point it at a workspace, not at C</>.

=head2 Testing

=begin code :lang<shell>
prove6 -Ilib t/           # with MCP::Server installed
prove6 -Ilib -I../MCP-Server/lib t/    # against a sibling checkout
=end code

C<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.

=head2 Examples

=item C<examples/filesystem-server.raku> — a two-root server (writable workspace, read-only reference tree) over stdio.

=head2 Author

Matt Doughty

=head2 License

Artistic-2.0

=end pod
