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.

Advertisement

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.

Advertisement

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.