Chapter 1 of 7 · 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.
{
"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:
{ "jsonrpc": "2.0", "id": 1, "method": "roots/list" }{
"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:
{ "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.
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:
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:
| Request | Target | Inside the root | Observed result |
|---|---|---|---|
read_file | packages/checkout/src/cart.ts | yes | 4.1 KB of source returned |
read_file | packages/billing/src/invoice.ts | no | error: Access denied - path outside allowed directories |
list_directory | packages/ | no | same error, same shape |
read_file | /etc/hosts | no | same error |
write_file | packages/checkout/src/cart.ts | yes | 4.1 KB written, mtime updated |
search_files | AWS_ under packages/checkout | starts inside | 2 hits, both inside the root |
read_file after list_changed added packages/billing | packages/billing/src/invoice.ts | yes, per the new list | error — 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.
| Behaviour | What you see | How to detect it |
|---|---|---|
| Enforces every root, re-reads on change | Paths outside are refused; a new root works without restart | Add a root mid-session, retry the previously refused path |
| Enforces, but caches at startup | Paths outside are refused; a new root works only after restart | The same probe, plus a restart to confirm the path was legal all along |
| Honours the first root only | The second root's paths are refused as though outside | Configure two roots and read a file in each |
| Treats roots as context, enforces from CLI args | Nothing is refused, or the refusals track --allow rather than the roots | Configure 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