# ServerFS MCP v0.2 — design baseline This is the **current** design and implementation contract. `dev_plan.md` describes the original v0.1 read-only release and is historical context: v0.1's "read-only is a product property, not an option" was deliberately superseded here by a per-workdir opt-in. `README.md` describes the user-facing behaviour; the tests and the implementation remain the executable truth. > A secure MCP server that exposes explicitly configured Linux directories as controlled > workdirs to AI agents, read-only by default with opt-in per-workdir file mutation. ## 1. What v0.2 adds v0.1 exposed read channels only. v0.2 keeps every one of them unchanged and adds exactly five mutation tools, available only in workdirs an administrator explicitly marks read-write: ``` create_text_file edit_text_file delete_file create_directory delete_directory ``` Tool surface is fixed at **11 tools** (6 read + 5 mutation). There is deliberately no `write_file`, `create_path`, `delete_path` or `filesystem_operation`. ### Out of scope (not "pending") Binary create/edit, rename/move/copy, recursive mkdir (`-p`), recursive delete (`rm -rf`), deleting non-empty directories, chmod/chown, symlink and hardlink creation, file upload, shell/command execution, Git tools, automatic backup or trash, database/index/RAG, ACL management, cross-workdir moves, resources with write semantics. ## 2. Access model Each of the 16 slots has `WORKDIR_XX_READ_ONLY`, default `true`: ```env WORKDIR_01_READ_ONLY=true # default: read-only WORKDIR_02_READ_ONLY=false # explicit opt-in to mutation ``` - Exactly one variable, controlling **both** the ServerFS authorization and the Docker bind-mount flag. A second access variable would allow the two layers to drift. - Parsing is strict: `true/false/1/0/yes/no/on/off` (case-insensitive), empty = `true`, anything else is a startup `CONFIGURATION_ERROR`. A security switch must never fail open. Compose itself only accepts `true`/`false` (`1`/`0` abort the deployment), so that is the documented form. - A disabled slot (empty alias + `.serverfs-disabled` sentinel) with `READ_ONLY=false` is a configuration error rather than a silently ignored setting. - `list_workdirs` reports `access: "read-only" | "read-write"` so the agent learns its reach in one call. The value is derived from `read_only`; there is no second source. - Authorization is the **first** gate: a read-only workdir answers `WORKDIR_READ_ONLY` even when the path would also fail the hidden/deny policy, and even if a human has mistakenly mounted the host directory writable. Container root stays read-only, non-root, `cap_drop: ALL`, `no-new-privileges`, no Docker socket. Only `/workdirs/XX` can become writable, and only from configuration. ## 3. Tool contracts ### create_text_file — the target must not exist Success requires: read-write workdir, non-root path, existing parent directory, a target that does not exist, policy-valid path, content ≤ `SERVERFS_MAX_WRITE_BYTES`, no NUL. Any existing object (file, directory, symlink, FIFO, socket, device) → `PATH_ALREADY_EXISTS`. There is no overwrite, force or upsert mode, by design: an agent that guesses a path wrong must be told, not obeyed. Content is written exactly as supplied — no newline normalization, no trimming, no appended final newline. UTF-8 encode failure or an embedded `U+0000` → `BINARY_CONTENT_NOT_ALLOWED`. New files are `0o666 & ~umask` (typically `0644`). ### edit_text_file — the target must exist, and be text Requires `expected_revision` from a previous `read_text_file`/`stat_file`. The target must exist (`PATH_NOT_FOUND`, never implicit creation — a mistyped path must not become a new file), be a regular file (`NOT_A_FILE`), not a symlink (`SYMLINK_NOT_ALLOWED`), valid UTF-8 (`UNSUPPORTED_TEXT_ENCODING`), NUL-free (`BINARY_FILE`), at most one hard link (`MULTIPLE_HARDLINKS_NOT_SUPPORTED` — an atomic replace would split the link), and within the write limit. Edits are exact-match only, never regex or fuzzy. Each edit declares `expected_count` (default 1) and fails with `EDIT_CONFLICT` if the actual count differs, or if the text is absent. `old_text=""` is legal only to fill a completely empty file. Edits apply in order to an in-memory working text; if any fails, **nothing** is written (all-or-nothing). ### delete_file — regular files, any content Binary files can be deleted even though only text can be edited. Requires `expected_revision`. Directories → `NOT_A_FILE`, symlinks → `SYMLINK_NOT_ALLOWED`, other types → `UNSUPPORTED_FILE_TYPE`. A file the process cannot *read* is refused with `ACCESS_DENIED`: holding directory write permission is not a licence to unlink a file you cannot open. No size limit; deletion is permanent. ### create_directory — one level, parent must exist No recursive creation. `PATH_ALREADY_EXISTS` for anything already at the path, including an existing directory. Mode `0o777 & ~umask`. ### delete_directory — empty only, never recursive Requires `expected_revision`. Emptiness is **physical**: hidden, denied and reserved entries count, and the error never names them. A non-empty directory → `DIRECTORY_NOT_EMPTY` (also enforced by `rmdirat`'s `ENOTEMPTY` as a second gate). The documented cleanup path is `list_directory` → `delete_file` per entry → `delete_directory`. ### The workdir root is never mutable `create_*`, `edit_*` and `delete_*` with `path=""` (or `.`, `./`, `sub/..`) → `ROOT_MUTATION_NOT_ALLOWED`. No exception. ## 4. One policy, eleven channels Every channel — `list`, `find`, `search`, `read`, `stat`, `resource`, `create`, `edit`, `delete file`, `mkdir`, `rmdir` — resolves through the same `ResolvedPath` and consults the same `DenyPolicy`. A new channel that re-derives rules instead of reading the policy off the resolved path is a defect. For a *would-be* path (create/mkdir), the rule is: if the path came into existence, it would have to be a path the agent may reach. So `create_text_file(".env")`, `create_directory(".ssh")` and `create_text_file("private.pem")` are refused before anything is created, and `SERVERFS_EXTRA_DENY_GLOBS` applies to them identically. `SERVERFS_DISABLE_DEFAULT_DENY=true` semantics are unchanged: it releases the built-in credential rules for reads *and* mutations. No mutation-only deny policy was added — `EXTRA_DENY_GLOBS` remains the compensating control and always applies. ### Reserved internal names Atomic create/edit needs a same-directory temp file. Those files are named `.serverfs-tmp-`; the prefix `.serverfs-tmp-` is a **hard reserved namespace**. The workdir registry's disabled-slot marker `.serverfs-disabled` is reserved alongside it: `WorkdirRegistry` reads that name from the host layout at startup and refuses to start when it finds it where it does not expect it, so a channel that could create it would turn a file write into a startup failure. (Deleting it is not reachable — a disabled slot has no alias, so no channel can address it — but the marker is ServerFS's own and is reserved in both directions rather than only where it bites.) No enabled workdir can legally contain it — the registry refuses to start on that layout — so the reservation costs no configuration anything. Both names: - not listable, findable, searchable, readable, stat-able, creatable, editable or deletable — in every configuration; - *not* released by `SERVERFS_ALLOW_HIDDEN`, `SERVERFS_DISABLE_DEFAULT_DENY` or `SERVERFS_EXTRA_DENY_GLOBS`; - direct access answers `RESERVED_PATH`; listings simply omit them; - `search_text` passes exclusions to ripgrep (`paths.RESERVED_RG_EXCLUDES`) so intermediate temp files are never read at all, with the result-path policy re-check kept as defense in depth. If the process is `SIGKILL`ed mid-mutation a temp file can survive on disk. It stays invisible to the agent forever; v0.2 ships no startup scavenger and no recursive cleanup, so an operator removes it on the host. This is the one accepted debris mode. ## 5. Revision and concurrency `read_text_file` and `stat_file` return `revision`: `v1:` + 16 hex chars of a SHA-256 over `(st_dev, st_ino, st_mode, st_uid, st_gid, st_size, st_mtime_ns, st_ctime_ns, st_nlink)`. The token is opaque — inode numbers, device numbers and UIDs never reach the agent — and it changes for content *and* metadata changes. Every page of a paginated read carries the same revision, so an agent can prove it read one consistent version. `read_text_file` fstats the FD before and after reading; if the revision moved it returns `FILE_CHANGED_DURING_READ` instead of content that does not match the revision it reports. Mutation concurrency: 1. **One process-wide lock** (`mutations.mutation_lock()`) serializes every mutation. Reads never take it. Mutation traffic is low; a per-path lock table would be premature. 2. **Revision re-check inside the lock**, and again immediately before the commit. 3. **Atomic publication** means a concurrent reader sees the complete old or the complete new content, never a partial file. Resulting guarantee: two callers holding the same revision cannot both commit — exactly one wins, the other gets `REVISION_CONFLICT`. Retry hints are intentionally conservative: a repeated `create_directory` performs no second mutation and is idempotent; repeated edit/delete calls with the same revision cannot commit twice. `create_text_file` is the exception and advertises `idempotentHint=false`: its no-overwrite algorithm creates a same-directory reserved temp file before `linkat`, so a failed repeat can still change parent-directory metadata/revision when that temp entry is created and cleaned up, even though the target file itself is unchanged. **Documented limitation.** This is compare-and-swap against *this process* plus a last-moment re-check, not a linearizable filesystem. POSIX has no atomic "compare inode and mutate pathname" primitive, so a non-cooperating external writer — a host user, an IDE, another container — can still rename a pathname between the final check and the `renameat`. ServerFS does not claim otherwise; a read-write workdir should be treated as shared space. ## 6. Implementation notes `mutations.py` owns revision, the lock, atomic create/edit, delete, mkdir/rmdir and metadata preservation. `fdio.py` remains the FD-based security boundary and gained `open_regular_at`, `open_dir_at`, `stat_at`, `create_temp_at`, `unlink_at`, `fsync_directory` plus `open_root`/`root_fd` — the single implementation of "open the workdir root", which `tools.py`, `filesystem.py` and `mutations.py` all delegate to. Every mutation walks the parent chain with `O_DIRECTORY|O_NOFOLLOW` and holds the parent FD; the final name is only ever passed to `dir_fd=`-relative calls (`linkat`, `renameat`, `unlinkat`, `mkdirat`, `rmdirat`, `stat`). No request-derived path is ever reassembled into a pathname. Publication: ``` create: temp(O_EXCL) → write → fsync → linkat(temp, target) # EEXIST = no overwrite → unlinkat(temp) → fsync(dir) edit: temp(O_EXCL) → write → preserve owner/mode/xattrs → fsync → re-check revision → renameat(temp, target) → fsync(dir) ``` Metadata preservation happens **before** the rename and failure is fatal: ownership is copied only when it differs (a foreign owner cannot be reproduced by a non-root container), mode is always copied, and xattrs are copied wholesale; any unreproducible piece raises `METADATA_PRESERVATION_FAILED` with the original file untouched. Losing a mode bit, an ACL or a SELinux label silently would be worse than a failed edit. The order — **ownership, then mode, then xattrs** — is load-bearing, not cosmetic. `chown(2)` clears `S_ISUID`/`S_ISGID` on the file it touches, so applying the mode first lets a successful `fchown` strip setuid/setgid from the published inode while the edit still reports success (measured: `0o2755` published as `0o755`). Copying xattrs last keeps the replacement holding what the *original* held rather than what the ownership change left behind, since ownership changes can disturb `security.*` metadata. Both properties are pinned by tests (`TestEditMetadata`). Durability has two different failure semantics on purpose. The temp file's own `fsync` runs *before* publication, so a failure there aborts the mutation with `MUTATION_IO_ERROR` and nothing is published. The directory `fsync` runs *after* publication, when the entry is already visible to every reader: a failure there means "not durable across a crash", not "nothing happened", so it is logged as `directory_fsync_failed` for the operator and the tool still reports the mutation it actually performed. Reporting a failed call for a committed change would contradict the filesystem and invite a pointless retry. The text-file contract is enforced on the *result* too, not only on the source: `edit_text_file` rejects `old_text`/`new_text` containing `U+0000` (`BINARY_CONTENT_NOT_ALLOWED`) exactly as `create_text_file` rejects it in `content`, so no mutation channel can turn a text file into one that every channel then refuses to read. Audit: every mutation emits one `tool_call` event with tool, workdir, relative path, duration, success, `error_code`, and per-tool counters (`bytes_written`, `edit_count`, `bytes_before`/`bytes_after`, `bytes_deleted`, `revision`). Content, `old_text`/`new_text` and any host/container path are never logged. ## 7. Configuration added in v0.2 | Variable | Default | Meaning | |---|---|---| | `WORKDIR_XX_READ_ONLY` | `true` | Read-only unless explicitly `false`; drives app authorization and the bind mount | | `SERVERFS_MAX_WRITE_BYTES` | `1048576` | Create content, edited source, edit result, and the summed `old_text`+`new_text` of one call | | `SERVERFS_MAX_EDITS_PER_CALL` | `50` | Edits accepted in one `edit_text_file` call | ## 8. Error codes added in v0.2 ``` WORKDIR_READ_ONLY PATH_ALREADY_EXISTS PARENT_NOT_FOUND ROOT_MUTATION_NOT_ALLOWED REVISION_CONFLICT FILE_CHANGED_DURING_READ EDIT_CONFLICT TOO_MANY_EDITS WRITE_TOO_LARGE BINARY_CONTENT_NOT_ALLOWED MULTIPLE_HARDLINKS_NOT_SUPPORTED METADATA_PRESERVATION_FAILED DIRECTORY_NOT_EMPTY RESERVED_PATH MUTATION_IO_ERROR ``` Reused unchanged: `WORKDIR_NOT_FOUND`, `PATH_NOT_FOUND`, `PATH_OUTSIDE_WORKDIR`, `HIDDEN_PATH_NOT_ALLOWED`, `DENIED_PATH`, `SYMLINK_NOT_ALLOWED`, `NOT_A_FILE`, `NOT_A_DIRECTORY`, `BINARY_FILE`, `UNSUPPORTED_TEXT_ENCODING`, `UNSUPPORTED_FILE_TYPE`, `ACCESS_DENIED`. All remain `CODE: short message`, built from the agent's own inputs, with no host path, container path, temp name, inode or UID. ## 9. Verification The v0.2 gate is the README's Development block (all six commands actually executed) plus a mutation smoke test on a **throwaway** compose stack: the production deployment serves a live tunnel and is never used as a mutation test target. Coverage summary: tool surface and annotations; RO/RW authorization on all five tools; the hidden × default-deny policy matrix on all five mutation channels; the reserved namespace on all eleven channels including a ripgrep-level check; create/edit/delete semantics (conflicts, types, limits, atomicity, metadata, hardlinks, BOM/CRLF/UTF-8 fidelity); revision stability, opacity and change detection; deterministic concurrency (two creates, two edits on one revision, edit vs delete, read-while-edit); audit content exclusion; and error-leak checks over the whole failing surface. Known residual limitations (accepted, not fixed in v0.2): - external renames can still race the final check (see §5); - a crash can leave a `.serverfs-tmp-*` file on the host; - a file owned by another user cannot be edited by a non-root container (`METADATA_PRESERVATION_FAILED`), and the same applies to metadata the process cannot reproduce (e.g. a `security.*` xattr without privileges); - the process-local lock does not coordinate a second ServerFS instance or a host editor.