An MCP server is a program that offers a model tools. A file server’s tools read and list files, and
which files is not decided by the model, by Claude Code or by the prompt: it is decided by the server,
from its own settings. This lab’s server was set up to share / — every file the user it runs as can
read — with links followed wherever they lead, dotfiles included, and a tool to write as well. The
assistant quoted a private key because nothing stopped it from reading one.
A stdio MCP server is a child process of the client. Claude Code starts it with the command in
.mcp.json, as the user running Claude Code, and talks JSON-RPC to it over stdin and stdout. The
server can therefore open anything that user can open: here, learner’s home, ~/.ssh included.
Unix permissions answer “may this user read the file”; they cannot answer “should the model see it”,
because the model and the user are, as far as the kernel knows, the same process tree.
So a server that exposes files needs its own boundary. This one reads it from
/etc/mcp-files/config.json on every call: roots (the folders a path must lie in),
follow_symlinks, show_hidden and read_only.
Every path a tool receives is resolved first — a relative path is taken from the first root — and then
checked: is it inside one of the roots? With "roots": ["/"], every absolute path is inside, and the
check means nothing. The fix everyone reaches for first is to narrow the roots to ~/notes, and it is
necessary. It is not enough.
~/notes/shared-keys is a symbolic link to ~/.ssh. The path ~/notes/shared-keys/id_ed25519 is
inside ~/notes as written — its text starts with /home/learner/notes/ — and the file it opens is
outside. A check on the path as written (os.path.abspath) passes it; a check on where it leads
(os.path.realpath, with the roots resolved the same way) refuses it. follow_symlinks: true is the
first kind. Links are how shared folders grow in practice — somebody links a directory in “for
convenience” — so a server has to decide on the real location, not the spelling.
Dotfiles are where applications keep configuration and, often, credentials: .env, .netrc, .git.
A notes folder has no business showing them to a model. show_hidden: false makes the server leave
them out of listings and refuse to read them, even when the name is asked for directly.
With read_only: false, the server offers write_file. A model that can write the runbook can change
what the next reader believes. The right shape is not a tool that checks a flag and says no: it is a
server that does not offer the tool at all, so it never appears in the model’s list.
The job allows mcp__files — every tool the server offers. Claude Code could narrow that
(mcp__files__read_file,mcp__files__list_directory), and that is worth doing. But permission rules
decide which tools may be called, not which paths: a model allowed read_file may pass it any
path at all. The paths are the server’s business.
Replayed on the lab’s container (Ubuntu 26.04, Claude Code 2.1.270; JSON shown on one line). The project’s server definition, and the settings the server reads:
$ cat ~/notes-bot/.mcp.json
{"mcpServers": {"files": {"type": "stdio", "command": "python3", "args": ["/opt/mcp-files/files_mcp.py"]}}}
$ cat /etc/mcp-files/config.json
{"roots": ["/"], "follow_symlinks": true, "show_hidden": true, "read_only": false}
$ ls -la ~/notes
-rw------- 1 learner learner 54 Sep 15 18:48 .env
-rw-r--r-- 1 learner learner 100 Sep 15 18:48 oncall.md
-rw-r--r-- 1 learner learner 207 Sep 15 18:48 runbook.md
lrwxrwxrwx 1 learner learner 18 Sep 15 18:48 shared-keys -> /home/learner/.ssh
claude mcp list in an interactive-style call shows the project server waiting for approval — yet the
headless job starts it and uses it; what counts is what the job does, so test the server directly. Two
JSON-RPC messages on its stdin are enough:
$ printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"/home/learner/notes/shared-keys/id_ed25519"}}}' \
| python3 /opt/mcp-files/files_mcp.py
{"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", ...}}
files: starting; roots ['/']
{"jsonrpc": "2.0", "id": 3, "result": {"content": [{"type": "text", "text": "-----BEGIN OPENSSH PRIVATE KEY-----\n(lab key)\n"}]}}
The log line went to stderr, the answers to stdout. Now narrow the roots and nothing else — the
settings file becomes {"roots": ["/home/learner/notes"], "follow_symlinks": true, "show_hidden": true, "read_only": false} — and ask again. Each line below is one call written to the server’s stdin the
same way, with its answer shortened:
read_file /etc/passwd → refused: /etc/passwd is outside the folders this server shares
read_file ~/notes/shared-keys/id_ed25519 → "-----BEGIN OPENSSH PRIVATE KEY-----..."
read_file ~/notes/.env → "ALERT_WEBHOOK=https://hooks.example/T7ac1899de40cf974"
Half the problem is gone and both of the review’s findings are still there. With all four settings right — roots on the notes, links checked where they lead, dotfiles hidden, read-only:
read_file ~/notes/shared-keys/id_ed25519 → refused: ... is outside the folders this server shares
read_file ~/notes/.env → refused: /home/learner/notes/.env is hidden
list_directory ~/notes → oncall.md runbook.md shared-keys/
write_file ~/notes/runbook.md → error -32602: unknown tool
read_file runbook.md → "# On-call runbook ..."
The link is still listed — it is a name in the folder — but it no longer opens anything outside it, and the relative path works from the first root.
.env. The next colleague links something in again, or keeps an .env
for a script. The grader creates fresh ones on every check for exactly that reason.chmod 000 ~/.ssh. The server runs as the same user as everything else, so the next thing that
needs the key breaks — and the server still reads every other file that user can.mcp__files from the job. The job then answers nothing; the task was to share the notes,
not to stop sharing.cat .mcp.json # which command starts which server
claude mcp list # what Claude Code makes of the definitions
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | <server command> # the server by hand
realpath ~/notes/some/link # where a path really leads
python3 -c 'import os; print(os.path.realpath(p).startswith(root))' # the check that matters
--allowedTools "mcp__files__read_file,mcp__files__list_directory" # narrow the tools too
Where this lab’s hints send you for reading, beyond this journal. In the TUI, h shows a
hint’s reading and l opens a journal section; the lab’s page on the site lists the same.
mcp) — What a server exposes is the server’s decisionManual pages: man 7 symlink, man 1 realpath.
Documentation:
The whole subject, end to end: the topic journals The Model Context Protocol (mcp), Claude Code in a job (claude-code) —
the mechanism, the symptoms and their causes, exercises, and sources.
A stdio MCP server runs as which user, and what can it therefore read?
The user who runs Claude Code, since it is Claude Code’s child process; anything that user can read, unless the server enforces a narrower boundary of its own.
Why does roots: ["/home/learner/notes"] not stop ~/notes/shared-keys/id_ed25519 from being read
when follow_symlinks is true?
The server checks the path as written, which starts with the root; the link inside the root points outside it. Only a check on the resolved path (realpath) refuses it.
What is the difference between refusing write_file in the tool and not offering it?
A tool that refuses still appears in the model’s list and invites attempts; a tool that is not offered cannot be called or planned around, and a later change to the check cannot open it.
Can a Claude Code permission rule keep a model from reading ~/.ssh through an MCP file server?
Not by path: rules allow or deny tools such as
mcp__files__read_file; the arguments are the server’s to check.
How do you test what an MCP server returns without a model?
Start its command yourself and write JSON-RPC to its stdin:
initialize, thentools/listortools/call; the answers come back on stdout, one per line, and the logs on stderr.