Revision history for MCP::Server::Tool::FileSystem
0.3.0 2026-10-24T21:44:51+01:00
- BUGFIX: [2026-10-24] Resolve configured roots directly with
physical-path. On Windows, IO::Path.resolve could duplicate a junction
target into the root path, making files under a valid junction root
appear missing.
- BUGFIX: [2026-10-24] Compare containment by volume and complete path
components, and stop probing descendants of a missing/non-directory
prefix. A 3,000-part missing path took over a minute on Windows and
held the toolkit lock beyond the containment test's deadline. The test
now stops on its first timeout instead of queuing further calls behind
that worker. The Windows volume cases (drives, drive-letter case, UNC
shares) are checked on every host under a Windows stand-in, so a
regression shows up before a Windows run.
- BUGFIX: [2026-10-24] Require MCP::Server 0.7.0+, which publishes
parameter defaults. Version 0.6.0 satisfied the previous dependency
pin but failed the schema contract tests because it silently dropped
those defaults.
0.2.5 2026-10-24T20:03:29+01:00
- BUGFIX: [2026-10-24] Windows installs of 0.2.4 failed at the test
step, in t/11-handles.rakutest, with no leak to find. Windows'
GetProcessHandleCount counts every handle the process owns -- threads,
events, the I/O completion port, handles waiting on a finaliser -- and
on GitHub's Windows runners it wandered by about 20 in either
direction over a batch whatever the batch's size (-11 then +20 over
130 tool calls, +23 then +20 over 40 searches), past the fixed
allowance of 10 the test held each batch to. The test now asks for a
garbage collection before every count, and fails a batch only when it
moves the count by more than one handle per tool call or search (one
per four reads of `$*DISTRO`), and never by less than 10, with batches
of 130 calls, 80 searches and 400 reads: limits of 130, 80 and 100,
four to six times the noise Windows showed, and at most a quarter of
the smallest leak the test exists for, which kept 1 descriptor per
read and 5 to 29 per call. Against the lib with that leak it still
fails every count, by 401, 1701 and 1215.
- MAINT: [2026-10-24] the containment walk always ends, and a test says
so. The walk climbs a path's parents until it meets the root or the
top of the filesystem, and a path that is not absolute has no top: the
parent of `..` is `../..`, for ever. No path a client sends can make
one -- every path the resolver builds is absolute, on every platform,
and a sweep of hostile spellings (backslashes, drive, UNC and device
prefixes, mixed separators, trailing dots and spaces, runs of
thousands of separators) through every tool, under both platforms'
rules, found nothing that hung -- but a Windows stand-in for
`$*DISTRO` on POSIX, the way tests exercise Windows code, read a link
to `C:\x` as a path on drive C: and climbed the resulting relative path
until killed. The walk now refuses a path that is not absolute, and
stops after as many steps as the path has characters whatever a
platform's parents do, so it ends on any input; the stand-in's read is
refused as escaping the root. (The fix itself went out in 0.2.4; this
entry and its test follow it.) New t/03 subtest runs the hostile
spellings through the tools, and the stand-in's read, each under a
deadline, on a pack of its own so a walk that never ended could not
wedge the rest of the file.
0.2.4 2026-10-24T19:29:01+01:00
- HOTFIX: [2026-10-24] Windows filesystem testing checks for errors on
POSIX reqs.
0.2.3 2026-10-24T18:51:14+01:00
- BUGFIX: [2026-10-24] `grep` believes an external searcher's file list
only when the searcher's own documentation calls its exit an answer,
and gives a searcher up only when it is gone or keeps failing. A
searcher killed by a signal part-way through its list -- which Rakudo
reports as exit code 0 -- had its partial list believed, and the files
it never got to were silently missing from the answer; a list with a
signal, or with any exit status but 0, 1, or 2 with nothing on stderr,
now costs that search a full scan. Exit 2 with a silent stderr is what
ripgrep, GNU grep and BSD grep all do, told to keep quiet about
unreadable files, when the root holds one: it lists every file they
could read, so it is now believed, where before one unreadable file
demoted the searcher for the rest of the process. Exit 2 with a
message (an unknown option, memory exhausted) is a refusal and is not
believed. One answer that cannot be believed no longer demotes the
searcher; five in a row, with no believed answer between them, drop
it, and a believed answer starts the count again. A searcher whose
binary has gone since its probe is dropped by the first search that
misses it, so the hundreds after it spawn nothing; one that is still
there but could not be launched (out of file descriptors, out of
processes) is tried again by the next search, and that never counts
against it. The probe likewise no longer remembers a "no" it never
got: a binary that is there but could not be launched, or was killed
before it replied, is asked again on the next call, while a binary
that is not there, or that ran and gave no version, is remembered as
before. New public `MCP::Server::Tool::FileSystem::reset-searchers()`
forgets all of it -- verdicts, drops and counts -- so the next search
probes from scratch: a harness can bind it to a command for a user who
has just installed ripgrep, or whose searcher was dropped in error.
New t/08 subtests drive stand-in searchers the test writes for itself
through each case, including resets during concurrent searches, and
t/07 runs every installed tier against a root holding a file nobody
may read.
- BUGFIX: [2026-10-24] a long-running server on macOS no longer runs out
of file descriptors. Rakudo 2026.09 builds `$*DISTRO` afresh on every
read (it files the object it built under a key its lookup never asks
for; rakudo/rakudo a22cab8f9), and on macOS each build runs `sw_vers`
and leaves a pipe to it open. This pack reads `$*DISTRO` for every path
component it handles, so each `fs_read` kept a dozen descriptors and a
search at least one per file it walked: at macOS's default limit of 256
a server lasted a few dozen calls before ripgrep could not be started
-- and was demoted to the Raku scan for the rest of the process -- and
soon after that no file could be opened at all. The pack now files the
`$*DISTRO` Rakudo built under the key the lookup reads, once, when it
loads: nothing changes on a release without the bug, and on one with
it every path operation stops costing a process spawn. And a searcher
that passed its probe but then cannot be started at all -- out of
descriptors, out of processes, its binary being rewritten by an upgrade
-- is no longer demoted for good: that search scans everything and the
next one tries again. New t/11-handles.rakutest counts the handles the
process holds open (the kernel's descriptor list on Linux and macOS,
GetProcessHandleCount on Windows) around a few hundred calls of every
tool and a hundred searches in a row; a new t/08 subtest pins the
retry. And t/08 no longer dies on a host with no system grep, where
its skip was a `return` outside any routine and failed the install.
- SECURITY: [2026-10-01] on Windows a path segment that would open
some other name than it spells is refused: one holding a `:` (a data
stream -- link::$INDEX_ALLOCATION is the directory `link` itself,
whatever kind of link it is), one ending in a dot or a space (Win32
drops them, so secret. IS secret), and one shaped like an 8.3 short
name (SECRET~1, PROGRA~1.TXT, a Samba server's SECRE~A1). None of
them leaves the root, but each let the name a permission layer in
front of the pack judged differ from the name the OS opened: a client
rule denying C:\work\secret was walked past by SECRET~1\x, which
Windows opens as secret\x. The file's full name always works; a file
whose own long name has the short-name shape cannot be reached on
Windows. POSIX is unchanged: all of these are ordinary names there.
New t/03 subtest pins the rule on every host, and the resolver's use
of it under a faked $*DISTRO.
- SECURITY: [2026-10-01] the device names refused on every platform are
the whole set Windows reserves: CONIN$, CONOUT$, COM0, LPT0 and the
superscripts COM¹-³ and LPT¹-³ join CON, PRN, AUX, NUL and COM1-9 /
LPT1-9, and the name is compared as Windows compares it -- before the
first dot, trailing spaces dropped -- so `NUL .txt` is refused too.
t/03 pins them.
- MAINT: [2026-10-01] moved the file-time test support from
`t/lib/FileTimeFixture.rakumod` into
`lib/MCP/Server/Tool/FileSystem/Test/FileTimeFixture.rakumod`
(`MCP::Server::Tool::FileSystem::Test::FileTimeFixture`), declared in
`provides`, per the fleet-wide convention: a `t/lib` directory is its
own CompUnit repo that starts cold on a fresh `zef install`, and
parallel test files pulling a fixture's dependency chain into that
cold store can race and fail the install outright.
- SECURITY: [2026-10-01] containment on Windows resolves `..` the way
Windows does. The symlink chase popped `..` against wherever the walk
had really got to, which is the POSIX rule; Windows collapses `..` by
name before the filesystem sees a path, and joins a relative link
target onto the link's directory and collapses it the same way. So a
link planted inside the root -- evil.txt -> L\..\..\secret.txt, with L
a link two levels deeper -- was judged to stay inside while Windows
opened a file outside the root, and fs_read returned it. Planting one
needs a symbolic link inside the root (Developer Mode, an elevated
process, or a checkout of a repository that carries one). The chase
now applies each platform's own rule; POSIX is unchanged. On Windows
it also refuses a device-path link target (\\?\..., \\.\...) and a
path segment made of dots and spaces other than `.` and `..`, which
Win32 would rename on the way in.
- CHANGE: [2026-10-01] `physical-path` is public API, documented under
"Resolving a path the way the OS will". It is what any code naming a
directory for the OS to check should use in place of IO::Path.resolve,
which does not follow symlinks on Windows and mis-joins a junction's
target onto the junction's parent. App::Sadna's sandbox profiles are
the first outside user. New t/10-physical-path.rakutest reads every
case through the OS and checks the resolver names the file the OS
opened; directory links are junctions on Windows, so it needs no
privilege there.
- BUGFIX: [2026-10-01] `stat` reports the right modification time. It
read IO::Path.modified, which on 64-bit Windows puts every file
within about two seconds of 1970 (MoarVM builds the nanosecond
timestamp by multiplying libuv's `long` seconds before widening, and
`long` is 32 bits there) and everywhere else is 37 seconds early (the
leap seconds are never added). It now reads the floating-point stat
time, which is right on every platform, and so does the map cache's
freshness key. t/02 sets a file's time outright and checks `stat`
reports exactly it.
- CHANGE: [2026-09-19] the published input schema now advertises every
default the handlers apply: `path = '.'` on list, glob, grep and map,
`regex = false` and `glob = ''` and `context = 0` and
`max-results = 50` on grep, `query = ''` and `focus = ''` and
`max-chars = 8192` on map, `replace-all = false` on edit, and
`recursive = false` on delete. The signatures in the Pod always said so;
the schema the model actually reads did not, so a call that omitted
`path` searched the whole root and looked to the model like an answer.
No handler changed -- `path` is still optional and still defaults to the
root -- and the new t/09-schema-contract.rakutest pins every claim
against the handler: each required parameter is called with that
parameter missing and has to be refused, and each declared default is
checked by calling the tool with the argument omitted and with the
declared value, and demanding the same answer both times.
(The defaults reach the wire with the MCP::Server release that
carries a param spec's `default` into the emitted schema; against an
older one they are simply not published.)
- FIX: [2026-09-19] concurrent fs_grep calls could kill the server
process outright. Several recursive Raku scans at once reliably tripped
a garbage-collector fault in MoarVM 2026.07 ("Heap corruption detected:
pointer ... to past fromspace"), which prints one line and calls exit():
no END block, no LEAVE, no signal handler, nothing at the Raku level
that could catch it. Measured at six concurrent searches, grep was the
only tool in the pack heavy enough to trigger it, and it failed 6 runs
out of 6. It now takes both of the measures that measured clean:
The scanning phase of every search runs under one PROCESS-WIDE lock
(0/6). Process-wide rather than per-instance, because the fault is in
the collector and does not care how many toolkits there are.
And when ripgrep or a system grep is installed, that tool is asked which
files contain the pattern and the scan opens only those (0/6) -- which
removes the allocation volume the collector was choking on. The call to
the searcher is made OUTSIDE the lock, so those stay parallel.
The external tool chooses candidate files and nothing else: it never
produces a line of the answer, never decides the order, and never sees
the glob, context or max-results. Every byte a model reads still comes
from the same scanner over the same walk, which is what makes the tiers
identical by construction rather than by careful formatting. A regex
search never leaves Raku (the pattern is a RAKU regex, safe only because
Rakudo refuses to interpolate one carrying a code block; ripgrep would
read it as Rust syntax and grep as POSIX), and neither does a pattern
outside printable ASCII (Raku searches NFG text, a searcher searches
bytes) or a search of a single file. A host with neither tool -- a stock
Windows, a minimal container -- is served by the Raku scan exactly as
before, now serialised.
- CHANGE: [2026-09-19] grep no longer ends an answer with
"[skipped N files that are not UTF-8 text]". Ripgrep passes a binary
file over in silence and cannot be made to count them, so that notice
was the one part of the output that could not survive the question
"which tier served this call?". Binary and mis-encoded files are still
skipped, on the same NUL sniff and the same decode as before; they are
simply not counted. "[stopped at N matching lines; raise max-results to
see more]" is untouched -- every tier reproduces it.
- NEW: [2026-09-19] MCP_FS_GREP_BACKEND pins grep to a tier (auto,
ripgrep, grep or raku), and MCP_FS_GREP_RG_BINARY /
MCP_FS_GREP_GREP_BINARY say which binary each tier spawns. They exist
for t/07-grep-parity.rakutest, which runs every kind of query through
every searcher installed on the machine and asserts the answers are
identical strings; they are also the escape hatch on a host whose
ripgrep is installed under another name. A tier is probed at most once
per binary per process -- the "no" cached as firmly as the "yes" -- and
a searcher that fails at work is demoted for the life of the process,
with that search falling back and answering normally.
- MAINT: [2026-08-27] moved the shared test fixtures from
C into
C
(C). A C
directory is its own CompUnit repo that starts cold on a fresh
C, and parallel test files pulling the fixture's
dependency chain into that cold store can race and fail the
install outright. C is staged and precompiled before the
test phase runs, so shipping test support inside the dist's own
namespace dissolves the race. This is the fleet-wide convention
going forward.
0.2.2 2026-08-21T21:05:33+01:00
- Require MCP::Server 0.6.0, the release that actually ships what 0.2.1
leans on: tool annotations in the tools/list catalog, the annotated-batch
concurrent scheduler, and the bridge's content-only rendering. The old
0.4.0+ pin let a resolver hand this pack a server that silently dropped
its annotations.
0.2.1 2026-08-21T20:36:03+01:00
- The six reading tools (read, list, glob, stat, grep, map) now declare the
MCP annotations readOnlyHint and idempotentHint, so a host may run a
batch of them side by side and execute identical calls once. The claim
is about the handlers as much as the intent: the observation tools take
this instance's mutation lock and the map cache is behind its own, so
several reads on ONE pack instance see coherent state. The five mutating
tools deliberately declare nothing.
0.2.0 2026-08-20T22:49:48+01:00
- New reading tool: map(path, query, focus, max-chars). Returns a ranked
skeleton of the code below a directory — each file as its path plus the
definition lines of its most-referenced symbols, never bodies — so a
model can orient in an unfamiliar tree with one call instead of a dozen
speculative reads. Ranking is a personalized PageRank over the
"file A refers to something file B defines" graph; query re-centres it on
matching identifiers and focus on named paths. Registered on read-only
kits too.
- map understands C, C++, Go, Java, JavaScript, Python, Rust, TypeScript
and TSX through TreeSitter::Native, and Raku through a heuristic scanner
(no tree-sitter grammar for Raku exists), where `use Foo::Bar` resolves
to the file that is Foo::Bar. The pack ships supplemental tree-sitter
queries for C and C++ (whose vendored tags.scm files tag no references at
all) and for TypeScript/TSX (whose vendored file tags only signature
declarations, missing every function with a body).
- map picks a file's representative definitions by how much the tree refers
to them, sharing a name's references among the files that define it and
setting aside names most of the map refers to. Matched-by-name references
cannot tell whose `new` they mean, and counting them naively made
"method new" the single line printed for three unrelated files.
- map's Raku scanner skips declarator documentation in every form, the
bracketed `#|( ... )` and `#=( ... )` blocks included. Their continuation
lines carry no `#`, so prose about code — which says "sub", "class" and
"constant" constantly — was being read as code and could outrank the
declarations around it.
- map bounds itself: prunes .git, node_modules, build output and every
dot-directory; skips files over 1MB, binaries and unreadable files, and
counts them; stops after 5000 files; and fits its answer into max-chars
by dropping whole files rather than truncating one. Parse results are
cached per file on modification time and size, so mapping, editing one
file and mapping again re-reads only that file.
- New dependency: TreeSitter::Native.
- Tool results now include structured `sha256-v1` revisions while preserving
their existing text. Files hash exact bytes; directories hash a canonical
recursive tree; absent paths have a stable token.
- SECURITY: write, edit, mkdir, move and delete accept the unadvertised
`_expected-revisions` condition. Compare and mutation share one provider
lock, stale conditions perform no mutation, and concurrent conditional
writers cannot both win.
0.1.1 2026-08-09T20:26:49+01:00
- SECURITY: symlink containment now holds on Windows. The defence relied
on IO::Path.resolve, which does not follow symlinks there, so a link
planted in the sandbox could be read and written straight through
(caught by the Windows CI lane). Containment now chases links itself
(physical-path, realpath-style over readlink, hop budget against
cycles, fail-closed on unreadable or drive-relative targets) — one
code path on every platform. path-contains unifies separator spelling
on Windows; the root itself is chased at construction. New containment
tests: chains, mid-path directory links, relative targets (in-root
'..' targets still read through), cycles.
0.1.0 2026-08-09T20:00:53+01:00
- Initial release
- MCP::Server::Tool::FileSystem: a root-confined filesystem toolkit that
plugs into any MCP::Server via $server.plug($kit) or
MCP::Server.new(:tools['FileSystem' => { root => '...' }]).
- Tools (default prefix "fs"): read, write, edit, list, glob, grep, stat,
mkdir, move, delete. A read-only kit registers only the five reading
tools — read, list, glob, grep and stat — and never advertises the rest.
- edit(path, old-string, new-string, replace-all): exact-match replacement
with no fuzzy matching. Zero matches, an ambiguous match without
replace-all, an empty old-string and an old-string equal to new-string
are all refused; the confirmation counts occurrences and bytes.
- read gained optional offset (1-based) and limit. Omitting both returns
the file byte for byte as before; a range comes back numbered "Ntext"
with a "[lines A-B of N total]" footer, and reading past the end is an
answer rather than an error.
- grep(pattern, path, regex, glob, context, max-results): GNU-grep-shaped
output ("path:line:text", "-" for context lines, "--" between groups).
Literal by default; regex mode is a native Raku regex, and a pattern
carrying a code block is refused by Rakudo's interpolation guard rather
than executed. Binary and non-UTF-8 files are skipped and counted, and
the search stops at max-results with a notice.
- move(from, to) and delete(path, recursive). Both ends of a move go
through the resolver; an existing destination, a missing destination
parent, and moving or deleting the root itself are all refused. A
recursive delete unlinks symlinks instead of following them.
- Files are read as bytes and decoded once rather than slurped as text:
Raku's text-mode slurp folds CRLF into LF and spurt never puts it back,
which would have made every edit of a CRLF file rewrite the lines it did
not touch. read is now verbatim in the sense edit needs it to be.
- Parameter naming is a documented contract: location parameters are
exactly "path", "from" and "to", and multi-word parameters are
kebab-case, so a permission layer in front of the server can find the
paths a call would touch by name.
- Containment: every path parameter must be relative, may not contain ".."
and may not name a Windows device; the nearest existing ancestor is
resolved and checked against the root by parent-chain walk, so symlinks
planted inside the sandbox cannot be used as a door out of it.
- glob is a portable, regex-free matcher: "*" matches within one path
segment, "?" matches one character, nothing else is special.