05 · Roots: Scoping a Server to a Workspace¶
A filesystem-capable MCP server connected to your machine is, in principle,
capable of reading or writing anywhere the process has permission to touch.
That's far more access than almost any real workflow needs. roots is the
MCP primitive for narrowing that down: the client declares which
directories (or, more generally, URI prefixes) a server should treat as
its working set.
Who declares roots, and when¶
Unlike tools/resources/prompts (which servers expose), roots flow in the
other direction: the client advertises them to the server, typically
right after the initialize handshake, and can update them later (e.g. the
user switches which project folder they're working in).
from mcp.types import Root
async def set_workspace_roots(session, workspace_paths: list[str]):
roots = [Root(uri=f"file://{path}", name=path.split("/")[-1]) for path in workspace_paths]
await session.send_roots_list_changed(roots)
A server that supports roots declares that capability during initialize,
and the client only bothers sending roots to servers that said they'd use
them.
Consuming roots on the server side¶
The server's job is to treat the declared roots as the only legitimate scope for filesystem-style operations, rejecting anything outside it:
import os
from pathlib import Path
from mcp.server.fastmcp import FastMCP, Context
mcp = FastMCP("workspace-files-server")
def is_within_roots(path: Path, roots: list[Path]) -> bool:
resolved = path.resolve()
return any(
resolved == root or root in resolved.parents
for root in (r.resolve() for r in roots)
)
@mcp.tool()
async def read_workspace_file(relative_path: str, ctx: Context) -> str:
"""Read a file, but only if it falls within one of the client's declared roots."""
roots = await ctx.list_roots() # URIs the client declared, e.g. file:///home/user/project
root_paths = [Path(r.uri.removeprefix("file://")) for r in roots]
# resolve relative to the first root, then verify it didn't escape via ../..
candidate = (root_paths[0] / relative_path).resolve()
if not is_within_roots(candidate, root_paths):
return f"Access denied: {relative_path} is outside the declared workspace roots"
return candidate.read_text()
The .resolve() + parent-directory check is doing the actual security
work here — without it, a relative path like ../../etc/passwd would
"resolve" outside the intended root while still looking like a harmless
relative path string at the call site. Always resolve to an absolute path
and check containment against the resolved root, never against the raw
string.
Roots are advisory, not enforced by the protocol¶
This is the detail worth sitting with: MCP defines how roots are communicated, but it has no mechanism that forces a server to respect them. A well-implemented server checks every filesystem operation against the declared roots, the way the example above does. A buggy or malicious server can simply ignore the roots entirely and read whatever the host process's OS permissions allow.
That means roots are a cooperative scoping mechanism between a trusted client and a trusted server, not a security sandbox against an untrusted one. If you're connecting to a server you don't fully trust, roots alone aren't the control that protects you — OS-level sandboxing (containers, restricted file permissions, a dedicated low-privilege user) is what actually enforces a boundary a server can't opt out of. Roots are about making a well-behaved server's default scope sensible and workspace-aware, not about containing a hostile one. (The broader security picture — prompt injection, tool poisoning, trust boundaries generally — is this level's next lesson.)
Handling root changes¶
A user switching projects mid-session should update the server's effective scope without requiring a reconnect:
@mcp.on_roots_list_changed()
async def handle_roots_changed(ctx: Context):
new_roots = await ctx.list_roots()
# invalidate any cached file listings, in-memory indexes, etc.
# built against the previous root set
file_index_cache.clear()
Servers that cache anything derived from the filesystem (a search index, a directory listing) need to treat a roots-changed notification as "the ground truth moved" and invalidate accordingly, not keep serving results built against the old workspace.
Exercise¶
Extend read_workspace_file with a matching write_workspace_file tool
that applies the same root-containment check before writing, and a
list_workspace_files tool that lists files recursively within the roots
only. Write a quick test that declares a root at a temp directory, confirms
a file inside it can be read/written/listed normally, and confirms a path
using ../ to escape the root is rejected by all three tools.