Skip to content
Model Context Protocol (MCP)Roots

Lead · 7 chapters

Roots

Scoping a server to specific parts of the client's workspace or filesystem

Treat roots as a useful scope contract, never as a security boundary; production isolation still requires enforcement outside the MCP server.

Read these first

What you will know

  • Scope a server to specific roots and verify it cannot read outside them

    Path traversal, symlinks, alternate URI forms, and normalization bugs can defeat a boundary that only checks string prefixes.

  • Run a cooperating server with roots configured and record which operations it permits and rejects

    A server may ignore, reinterpret, or only partially honor the client-provided roots, so its observed behavior matters more than the declaration.

  • Configure roots for a monorepo

    Overlapping package roots and shared tooling directories can unintentionally expose sibling packages or repository-wide secrets.

  • Test the boundary rather than assuming it

    Negative tests should cover traversal, symlink escapes, absolute paths, malformed URIs, and operations that use a different filesystem API.

  • Pair roots with an OS-level sandbox and demonstrate the difference against a malicious server

    Roots communicate intended scope, while a sandbox applies permissions even when the server is buggy or hostile.


Chapters

  1. 1

    The roots capability

    Roots run the opposite way from most MCP features: the client declares the roots capability at initialize, answers roots/list when the server asks, and sends notifications/roots/list_changed when the set moves, while the server asks and then decides what to do with the answer. The specification only says a server should respect the boundary, so the declaration binds nobody — point a cooperating server at one package and record which operations it permits and rejects, because its observed behaviour is the only evidence there is.

  2. 2

    Roots and OS-level sandboxes

    Configure file:///srv/app/web as the only root and a third-party server can still return /etc/passwd without violating the protocol, because no component in the stack inspects its filesystem calls against your list. Roots communicate intended scope, which a sandbox cannot express; a sandbox applies permissions to a buggy or hostile server, which roots cannot. A pull request description merging the two gets approved with nothing confining the workspace, and the reachable set stays three orders of magnitude wider than the root implies.

  3. 3

    Converting a root URI to a path

    Roots arrive as file:// URIs and every containment check operates on a bare filesystem path, so something has to convert one into the other — and that conversion is its own place for the boundary to fail. Percent-decoding has to happen before any prefix comparison, or the encoded form of a separator walks straight through it; a non-empty host component means the URI is not yours to serve, so file://localhost/etc/passwd and file://attacker/share are rejected rather than trimmed; and Windows drive letters and UNC forms parse by different rules than POSIX paths do. Get this step wrong and the careful check that follows is applied to a path you were never actually handed.

  4. 4

    Lexical path checks and realpath

    Resolving an incoming path and requiring the result to start with the root correctly rejects ../ traversal and the /srv/app/webhooks prefix trick, then passes /srv/app/web/uploads/logs where uploads is a symlink to /var/lib — the escape happening when open follows the link, and 1.4 MB coming back from outside the root. Switching to realpath closes that hole and opens a 40–90 µs window between the check and the syscall, which is a real improvement and not a closed class; hard links and bind mounts are outside what any path resolution can see.

  5. 5

    Unguarded filesystem entry points

    read_file and list_directory refuse to leave the root while search_code returns hits from a sibling package — not a contradiction, but a second filesystem API that never passes the read path's check: a spawned ripgrep, a prebuilt index, a glob library following symlinks by default. This is why the negative-test matrix runs traversal, symlinks, encoded paths, absolute paths and the malformed URI forms against every operation the server exposes, generated from tools/list rather than hand-maintained, and why a system-call trace finds what no matrix imagined.

  6. 6

    Confinement as evidence

    An auditor asking whether a third-party server can read ~/.aws/credentials is not answered by roots plus a green negative-test matrix, since both measure the server's cooperation at higher and higher resolution. Show the read failing with EACCES or ENOENT under a container mounting only the project directory, or a seatbelt profile denying the rest, with the server's code untouched. That holds whether the server is careful, buggy or hostile, and it survives the dependency bump that none of the other artefacts survive.

  7. 7

    Multiple roots and the union rule

    roots/list returns an array with no ordering, precedence or nesting rules, so membership is a union: a path is allowed if it lies inside any entry, and adding the repository root to a list of package roots makes the package entries decoration. Rooting packages/checkout in a pnpm monorepo breaks typechecking where node_modules symlinks leave the root, and both tempting fixes grant the whole repository — 340 readable files become roughly 61,000, plus .env, .git and infra. Mount the store read-only into the server's filesystem view instead, and price what widening actually hands over.

External resources