A Model Context Protocol server does not know where it is. A filesystem server launched by an editor has a working directory it did not choose and an entire disk it can technically read — and no idea which two folders the user actually cares about. Roots are the protocol’s answer: the client tells the server, in URIs, which parts of the world are in play. The alternative is a server that either demands an absolute path in every tool call or wanders the disk guessing. It is also the feature most often misread — a root looks like a fence, and people reason about it as one. It is not. This piece walks what roots are, how they are declared and updated, the sharp difference between an advisory boundary and an enforced sandbox, and the failure modes that show up when the two are confused.
What a root actually is
A root is a URI, supplied by the client, naming a location the server is expected to work within. In practice it is a directory: the folder the user opened in their editor. Each root usually carries a human-readable name alongside the URI so a server can say ‘searching backend’ rather than echoing a 90-character path.
The important structural point is who speaks. Roots flow from client to server, inverting the usual direction of MCP feature traffic: tools, resources, and prompts are things a server offers upward, while roots are one of the small set of things the client offers downward, alongside sampling and elicitation. Like those, the server has to ask, and cope when the answer is unavailable. A root is context the host possesses and the server does not — the server knows how to read files, the host knows which files the human is looking at.
The problem roots solve
Picture a filesystem server without them. It starts with an inherited working directory that may be the user’s home folder, the editor’s install path, or /. Every tool it exposes now needs an absolute path argument, which means the model has to invent one. Models invent plausible paths, not correct ones, so you get a long tail of calls against directories that do not exist and, worse, calls that succeed somewhere nobody intended.
Roots collapse that. The server asks once, learns that the workspace is file:///home/dev/api-service, and can now resolve relative paths against something real, describe its own scope accurately in tool descriptions so the model stops guessing, and decline requests that fall outside what the user has in view. Roots are how the host answers ‘which ones?’ without a configuration file per server.
Declaring roots: the roots/list request
Roots ride the same capability negotiation as everything else in MCP. During the initialize handshake the client advertises that it supports roots, and typically also whether it can notify the server when the list changes. A server that sees no roots capability should not read that as an empty workspace — it is talking to a host that does not do roots, and should fall back to explicit path arguments or its own configuration.
When the capability is present, the server issues a roots/list request and receives the current set back. Note that this is a pull, not a push: the client does not volunteer the workspace at startup, the server asks. So a server should ask early — right after initialization — and hold the result, rather than paying a round trip inside every tool invocation. Cached roots are the normal design, which is precisely why the change notification exists.
When the workspace moves: the list-changed notification
Workspaces are not static. A user adds a second repository, closes a folder, switches projects, or the host narrows scope after a permission prompt. A server that cached the root set at startup and heard nothing since would keep operating against a picture of the world that expired ten minutes ago.
MCP handles this with a list-changed notification sent from client to server. It is deliberately thin: no payload describing what changed, only that something did. The server’s obligation is to re-issue roots/list and replace its cached set wholesale. That notify-then-refetch pattern recurs throughout the protocol — the same shape governs tool, resource, and prompt list changes — because a diff protocol would require both sides to agree on identity and ordering, while a refetch requires only that the client can produce its current state. Servers that ignore the notification do not fail loudly; they quietly operate on stale scope, which is worse.
Advisory boundary, not enforced sandbox
This is the point worth being blunt about. A root is a declaration, not a restriction. Nothing in the protocol prevents a server from reading a file outside every root it was told about. It is an OS process with whatever permissions its parent gave it; the client has handed it a JSON list of URIs and asked nicely. There is no interposition layer, no capability handle, no revocable file descriptor. If the server calls open() outside every root, the kernel — not MCP — decides what happens.
Read that way, roots are a coordination mechanism: they let a cooperative server do the right thing without the user configuring it. What they are not is a security control, because a security control has to hold against a component that is malicious or compromised, and roots hold only against one that is well-behaved. A boundary you cannot enforce is a convention, and conventions are useful right up until you rely on them for safety.
Where enforcement actually has to live
If roots do not enforce, something else must. There are two honest places to put it, and mature deployments use both.
The first is inside the server. A well-written filesystem server treats its root set as a policy it applies to itself: every incoming path is resolved to a canonical absolute form — symlinks followed, .. collapsed, percent-encoding decoded — and only then compared against the canonical form of each root, segment by segment rather than by string prefix. Voluntary, but it is the layer that catches honest mistakes and model-generated nonsense.
The second is outside the server entirely: run it in a container or jail that mounts only the root directories, under an account with access to nothing else. Then the boundary is enforced by the kernel and holds regardless of what the server code wants. The rule of thumb: use roots to tell a server where to work, and use the OS to make sure that is where it stays.
Multiple roots and precedence
A client may declare several roots at once, and multi-root workspaces are ordinary rather than exotic — a frontend and a backend checkout, a project alongside its documentation. The list is flat and carries no meaningful order, which has consequences: there is no protocol-level notion of a primary root, and a server that treats the first entry as ‘the’ workspace will misbehave the moment a user adds a second folder.
The real work is disambiguation. If two roots both contain src/config.ts, a relative path is ambiguous and the server must either search all roots and return multiple hits or require the caller to name a root. Results that identify which root they came from, using the root’s human-readable name, beat bare paths: they give the model what it needs to be specific next call. Nested roots deserve care too — if one sits inside another, deduplicate rather than reporting every match twice.
URI forms beyond the filesystem
Roots are URIs rather than paths, which is deliberate forward design. In current revisions of the specification the practical constraint is that roots are file:// URIs — that is what hosts emit and what servers should expect — but the abstraction was chosen so the same mechanism can later scope things that are not directories.
The generalization writes itself once you see the pattern. The question a root answers is ‘which instances of the thing you operate on are in play?’ For a filesystem server that is directories; for a repository server it would be repository URLs, for a database server connection or schema URIs, for an API-wrapping server base endpoints. Until the spec widens, servers in those domains carry their own scoping configuration. The advice for authors is not to invent private URI schemes and pass them as roots hoping clients cooperate — a scheme the host cannot render is a scope the user cannot meaningfully consent to.
Consent: what the user is actually agreeing to
Roots are the place a host can make scope legible to a human. Adding a folder to a workspace is an act the user already understands, so a host can treat it as the consent gesture and skip a separate dialog — but only if it is honest about what that gesture implies. Handing a root to a server is not granting read access to a directory; it is telling a process that already holds your account’s permissions which directory to focus on.
The consequence is that the meaningful consent moment is earlier: when the user installs or connects the server at all. Root changes afterwards are scope adjustments within an existing trust relationship. Hosts that get this right show the current root set persistently, make removal immediate and obvious, and never expand the set without a user action — servers must not be able to request additional roots for themselves. A host that presents roots as a permission grant teaches a mental model that will fail the first time a server ignores it.
Failure modes worth designing against
Four recur often enough to test for explicitly.
| Failure | What happens | Defense |
|---|---|---|
| Server ignores roots | Reads or writes anywhere its process can reach | OS-level sandbox; treat roots as a hint, not a guarantee |
| Path traversal | ../../../etc/passwd resolves outside the root | Canonicalize first, compare segments after; never prefix-match raw strings |
| Symlink escape | A link inside the root points outside it | Resolve links before checking; decide deliberately whether links may leave |
| Root removed mid-operation | A long scan continues against revoked scope | Recheck the cached set at operation boundaries; abort in-flight work |
The subtle one is the last. A traversal bug is a bug and gets fixed; a long-running indexing job still reading a folder the user closed two minutes ago is correct under a naive reading of the protocol and still violates the user’s intent. Roots are state, and cached state needs a defined moment where it is rechecked.
roots/list after the handshake, cached, and refreshed whenever the client fires a list-changed notification. The one thing to keep straight is that a root is a coordination mechanism, not a security control. Nothing in the protocol stops a server from reading outside one, so real confinement has to come from the server validating canonicalized paths against its own root set, and from the OS — a container, restricted mounts — enforcing what the protocol only suggests. Design for multiple roots without assuming a primary one, name them so results are unambiguous, and recheck the cached set at operation boundaries so revoked scope actually takes effect.