Skip to content
Chapter 1 of 7 · RootsThe roots capability

16 min read

The roots capability

On this page

A root is a directory the client declares to the server as the place the user is working, expressed as a file:// URI and handed over when the server asks for it. Roots are one of the primitives of the Model Context Protocol (MCP), the JSON-RPC conventions a client and a server use to tell each other what they can do, and they exist because a server has no independent way of knowing which part of a machine matters. A filesystem-shaped server given nothing else faces either the whole of the account it runs as or a single directory frozen into its command line at launch, and neither of those survives a session in which somebody opens a second project at eleven in the morning. The root list is the client's standing answer to the question of where the server should be looking, kept current for as long as the session lasts.

What a server does with that answer is its own business, and the useful cases are ordinary ones: scoping a code search to one package rather than thirty, picking a default working directory so that a relative path means something, choosing which prebuilt index to load, or writing "searching in web frontend" into its output where a user can read it. You meet roots from one of three sides. In an editor-shaped client the root set is the list of folders you have open, and it moves when you open another one. If you are writing a client, roots are a request handler you implement and a capability you declare during the handshake. If you are running somebody else's server against a workspace, they are a line in a configuration file — "roots": ["file:///srv/app/web"] — whose effect you will have to establish by watching what the server then does.

Which direction the request travels

Almost every wrong intuition about roots follows from assuming they work like tools or resources. Tools, resources and prompts all live on the server: the server publishes them, the client enumerates them, the client calls them. Roots live on the client. The client publishes them, the server enumerates them, and then the server does whatever it was going to do anyway.

That inversion is forced by where the fact lives, and following the reasoning is worth a paragraph because the rest of the design falls out of it. A server is a process the client launched, usually with a fixed command line; it has no window, no folder tree, and no way to observe that somebody has just switched projects. The client is the process the user is operating, so the answer to which directories matter right now both lives on the client and changes on the client. A protocol can move a fact in one of two directions: the holder pushes it, or the party that needs it asks. Pushing would require the client to know when the server cares, and the server's need arrives at moments the client cannot see — its first tool call, an index rebuild, a reset of its own internal state. Asking leaves the timing with the party that has the need, which is why the holder of the answer ends up as the responder and the needer ends up as the requester.

The handshake sets this up explicitly. In initialize, the client lists what it can provide, and roots is one of the three things in that column alongside sampling and elicitation.

json
{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-06-18",
    "clientInfo": { "name": "workbench", "version": "0.9.2" },
    "capabilities": {
      "roots": { "listChanged": true }
    }
  }
}

roots: {} means the client will answer a roots/list request. The listChanged: true flag adds a promise on top of that: when the set of roots changes during the session, the client will send a notification so the server can ask again. A client that opens one fixed folder at launch and never changes it can honestly declare roots without listChanged.

Once the session is live, the request goes the other way:

json
{ "jsonrpc": "2.0", "id": 1, "method": "roots/list" }
json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "roots": [
      { "uri": "file:///srv/app/web", "name": "web frontend" }
    ]
  }
}

Each entry carries a uri and an optional human-readable name. The specification requires file:// URIs in the current revision, with the door left open for other schemes later, and the name exists so a server can say "searching in web frontend" rather than printing a path.

When the user opens a second folder, the client does not push a new list. It sends a notification and waits to be asked:

json
{ "jsonrpc": "2.0", "method": "notifications/roots/list_changed" }

Sending a bare notification rather than the new list is deliberate, and the reason is the same one that makes listChanged worth declaring. A notification carries no id and gets no reply, so the client has no way to know what the server was in the middle of when it arrived. If the notification carried the list, the server would be adopting a new boundary at a moment it did not choose — possibly part-way through a directory walk begun under the old one. Carrying nothing, the notification says only your copy is stale, and the server issues roots/list at a point where replacing the list is safe for it. The price of that choice is that the decision to re-ask, and therefore whether the two views of the workspace stay in step, sits entirely on the server side.

Sequence: Client → Server (initialize (capabilities.roots.listChanged)); Server → Client (initialize result); Client → Server (notifications/initialized); Server → Client (roots/list (id 1)); Client → Server ([file:///srv/app/web]); Client → Server (notifications/roots/list_changed); Server → Client (roots/list (id 7)); Client → Server ([file:///srv/app/web, file:///srv/app/api]).

Anyone who has implemented a language server will recognise the pattern precisely, because it is the same one. The Language Server Protocol (LSP), the standard an editor speaks to a compiler-aware backend, has workspace/workspaceFolders, a request the server sends to the client, and workspace/didChangeWorkspaceFolders, a notification the client sends when the set moves. MCP inherited the design for the same reason LSP arrived at it: the editor is the thing that knows what the user has open, and a folder set that arrives once as a command-line argument goes stale the moment somebody opens a second project. Roots exist so a long-lived server can track a workspace that moves under it.

Two neighbouring things are worth separating out here, because the direction is what distinguishes them. A root list is not a resource listing: resources/list is the server telling the client what it can offer, and it points at things the server has decided to expose, while roots/list is the client telling the server where the user's attention is. The two can name the same directory and mean opposite things. Nor is a root list a filter the transport applies. There is no interception layer between the server and its open() calls — the list is a JSON array that arrived in a response, and whether it constrains anything is a property of the server's source code.

Answering the request from the client side

Because the client is the responder here, implementing roots means writing a handler rather than calling an API, and the handler is small enough that the interesting decisions are all about what it refuses to say. In the TypeScript SDK it is a callback registered against the request schema:

ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { ListRootsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { pathToFileURL } from "node:url";

const client = new Client(
  { name: "workbench", version: "0.9.2" },
  { capabilities: { roots: { listChanged: true } } },
);

client.setRequestHandler(ListRootsRequestSchema, async () => ({
  roots: workspace.openFolders
    .filter((f) => f.isDirectory && !f.isExcluded)
    .map((f) => ({ uri: pathToFileURL(f.path).href, name: f.label })),
}));

// When the user opens or closes a folder:
workspace.on("foldersChanged", () => {
  void client.notification({ method: "notifications/roots/list_changed" });
});

Two things in that handler are worth doing deliberately. The list is computed at request time from the workspace rather than captured once at construction, which is what makes the listChanged promise honest — a client that declares the flag and then answers from a stale snapshot has told the server to re-ask and given it the same answer. And the filter runs before the mapping, so a folder the user has open but the client has decided not to expose never becomes a URI. The specification asks clients to validate that the roots they advertise exist and that the user has permitted access to them, and that validation belongs here, at the point of emission, because there is no later point at which anybody checks.

The pathToFileURL call is not decoration. A path containing a space, an accent, or a # has to be percent-encoded to be a legal URI, and hand-assembling "file://" + path produces a string that some servers will parse into a different path than the one you meant.

The # is the sharpest of those three, because nothing announces the failure. A filesystem treats # as an ordinary character in a name, so a directory can be called web#staging and nothing objects. A URI parser treats # as the start of the fragment, and the fragment is not part of the path. Concatenating gives file:///srv/app/web#staging, whose path component stops at the #, so the server is told its root is /srv/app/web. Truncation can only ever shorten a path, which means the mangled root is never a subdirectory of the one intended — it is some other directory at the same level, or an ancestor of it. The client believes it narrowed the server's view, has no reply that would tell it otherwise, and percent-encoding the character is the whole of the fix. Chapter 3, on turning a root URI back into a filesystem path, is about the receiving side of that mistake.

What a cooperating server actually does with the list

The protocol says nothing about what happens next. A server may store the list and constrain every path operation to it, store it and use it only to pick a default working directory, use it to decide which of several indexes to load, or discard it entirely. All four are conformant. The specification's language is that servers should respect root boundaries, and "should" in the sense fixed by RFC 2119, the document that pins down what MUST and SHOULD mean in a specification, is advice with a reason attached rather than a constraint the transport enforces.

Which means the only way to know what a given server does is to run it and watch. Take a well-behaved filesystem server, configure exactly one root at packages/checkout inside a monorepo, and walk the surface it exposes, recording each result. A representative pass looks like this:

RequestTargetInside the rootObserved result
read_filepackages/checkout/src/cart.tsyes4.1 KB of source returned
read_filepackages/billing/src/invoice.tsnoerror: Access denied - path outside allowed directories
list_directorypackages/nosame error, same shape
read_file/etc/hostsnosame error
write_filepackages/checkout/src/cart.tsyes4.1 KB written, mtime updated
search_filesAWS_ under packages/checkoutstarts inside2 hits, both inside the root
read_file after list_changed added packages/billingpackages/billing/src/invoice.tsyes, per the new listerror — until the process is restarted

The pairing in that table is what turns it from a list of outcomes into evidence, and it is worth spelling out because a matrix built without it looks stronger and proves less. Each operation appears against a target inside the root and against a target outside it, and the inside row is not a formality. A tool that is broken, misconfigured, or pointed at the wrong directory refuses everything it is given; a matrix of outside-targets only would come back as a denial in every row and read as a flawless boundary. The inside row eliminates that explanation. It shows the tool working, which leaves the target being outside the root as the reading of its partner row. Each pair verifies the harness that produced it, which is why a matrix with the inside column missing is not a weaker version of this one — it is a different instrument, and it cannot tell a boundary from a broken tool at all.

Six of those seven rows say the server is doing what you hoped. The seventh is the one worth writing down. That server asked for the roots once, at startup, cached them, and never re-requested after the list_changed notification, so the client's view of the workspace and the server's view diverged silently for the rest of the session. Nothing was violated. The client kept its promise to notify; the server simply did not act on the notification. A user who had opened a second package and was watching an agent insist the files did not exist would have no way to attribute that to a caching bug from the outside.

Run the same pass against a different server and the shape of the table changes. Some servers treat roots as advisory context and accept any absolute path you hand them, having also been given --allow /srv on the command line, in which case the root narrows nothing. Some accept the first root and ignore the rest, which is a plausible reading of a list for a server that only ever needed one working directory. Some reject paths outside the roots for reads and allow writes anywhere, because the check was added to one function and not the other, which is the subject of chapter 5, on why enforcement is a property of each code path rather than of a server. The declaration in the config file is identical in all of these cases; the observed behaviour is not.

Those variations are worth naming, because "the server supports roots" turns out to describe at least four different behaviours, each of which is compliant and each of which fails differently. The probe that distinguishes them takes about ten minutes with a bare client and no model in the loop.

BehaviourWhat you seeHow to detect it
Enforces every root, re-reads on changePaths outside are refused; a new root works without restartAdd a root mid-session, retry the previously refused path
Enforces, but caches at startupPaths outside are refused; a new root works only after restartThe same probe, plus a restart to confirm the path was legal all along
Honours the first root onlyThe second root's paths are refused as though outsideConfigure two roots and read a file in each
Treats roots as context, enforces from CLI argsNothing is refused, or the refusals track --allow rather than the rootsConfigure a root narrower than the CLI argument and read between them

The third row is easy to miss because a single-root configuration cannot expose it, and single-root is how most people test. The fourth is the one that produces the most confident wrong conclusions, because the server does refuse things — just not the things your root list describes — and a matrix that only tries /etc/passwd will record a refusal and call the boundary verified. Configuring a root that is strictly narrower than whatever the server was already allowed is what separates a server that read your list from one that ignored it and happened to refuse for its own reasons.

Why strictly narrower rather than merely different is a question about what a refusal can prove, and it is the same reasoning any discriminating test rests on. Two hypotheses are on the table: the server enforces the root list, or it enforces its command-line argument and files the root list under context. Suppose the argument is --allow /srv and the root is /srv/app/web. Reading /etc/hosts is outside both sets, so both hypotheses predict a refusal and the observation separates nothing. Now read a file under /srv/app/api, which is inside the argument and outside the root. That target sits in the gap between the two sets, exactly one hypothesis predicts a refusal there, and the answer decides between them. A root that is not strictly narrower leaves no gap, and with no gap there is no probe.

Clients that never answer

A great many clients do not implement the capability at all, which is why almost every filesystem-shaped server also accepts directories as command-line arguments and treats the argument list as its allowed set. When a server sees no roots capability in initialize, it falls back to those arguments, and if there are none it may default to the process working directory or to nothing at all. A server written to require roots would be unusable in half the clients on the market, which is a design constraint on anything you write rather than a defect in the clients.

And roots are not a security boundary, which is the claim chapter 2 is entirely about, because it is the one that reliably survives being told once and then gets made again in a pull request description. Nothing above obliges a server to consult the list, and the four conformant behaviours include one in which it does not.

That belief is worth naming rather than only forbidding, because holding it is not carelessness. Two things make it reasonable. The word root is borrowed from a family of mechanisms that do enforce — chroot, a container's root filesystem, a web server's document root — where naming the directory is the entire act and the kernel or the runtime supplies the rest; the roots capability takes the word and none of that machinery. And a cooperating server really does refuse, so the first thing anybody tries comes back denied, and a denial feels like proof of a boundary no matter which line of code produced it. What is actually true is narrower and follows from the mechanism above: a root list is a JSON array that arrived in a response to a request the server chose to send, and an array in a response constrains nothing except the code that decides to read it.

The practical consequence of a client-side, advisory list is that the evidence you can gather is behavioural. You can read a server's source, which tells you what it does today in the code paths you read. You can read its documentation, which tells you what its author intended. Neither is a substitute for pointing it at one directory and recording, operation by operation, what it permits and what it refuses — including the operations you did not think of as filesystem operations, and including a list_changed round trip to see whether the server is still listening.

Keep that table. It is the artefact that survives a version bump, and re-running it after an upgrade is a ten-minute job that has caught servers whose path checks moved. What the table cannot tell you is what happens when the server is not cooperating at all: when it has a bug in a code path your matrix did not exercise, or when its next release pulls in a dependency that reads files on its own. Those are the cases where the difference between a declaration and an enforced boundary stops being a technicality, and that is where we go next.

Roots — read the current version at https://daviddominguez.dev/learn/en/roots/the-roots-capability

Back to top