C.W.K.
Stream
Lesson 03 of 05 · published

Root Directory Scope

~18 min · roots, scope, filesystem, sandboxing

Level 0Curious Reader
0 XP0/48 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete

We met Roots as a client capability in the primitives track — the host tells the server which directories it is allowed to operate in. Re-read it now through a security lens: Roots are how a filesystem-touching server avoids accidentally roaming.

Without Roots, a filesystem MCP server has to assume something about its scope: maybe $HOME, maybe the current working directory, maybe whatever a user typed. Every assumption is wrong for some user, and the wrong-for-this-user case is exactly the kind of accident that turns into "the AI wrote my SSH config." With Roots, the host is the one who answers the scope question, derived from whatever the user-facing UX (workspace, opened folder, picker) says is the active context.

The protocol pattern: server lists roots on initialize, optionally subscribes to notifications/roots/listChanged, and refuses any tool call whose argument paths fall outside the current root set. Refuse politely — return a structured error, not a stack trace — so the LLM can correct course on the next turn. "Outside roots" is a contract violation by the model, not a bug in the server.

Code

Refusing out-of-root paths·python
from pathlib import Path

def assert_in_roots(path_str: str, roots: list[str]):
    p = Path(path_str).resolve()
    for r in roots:
        if str(p).startswith(str(Path(r.replace('file://','')).resolve())):
            return
    raise PermissionError(f"path {p} is outside the active roots")

@app.tool()
async def read_file(path: str) -> str:
    roots = await app.request_context.session.list_roots()
    assert_in_roots(path, [r.uri for r in roots.roots])
    return open(path).read()
Subscribing to root changes·python
# When the user switches workspaces in the host, roots/listChanged fires.
# Refresh your scope on receipt; do not cache aggressively.
@app.notification("notifications/roots/listChanged")
async def on_roots_changed(params):
    fresh = await app.request_context.session.list_roots()
    update_my_scope(fresh)

External links

Exercise

If you have written a filesystem-touching server, audit it for hidden assumptions about scope ($HOME, cwd, hardcoded paths). Replace each with a Roots query at request time. The audit is short; the safety improvement is permanent.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.