Understanding Roots
When you give an AI agent access to your file system, you face a significant security risk. You might want the agent to read documentation in /home/knight/docs, but you definitely do not want it reading your SSH keys in ~/.ssh or deleting system files.
In MCP, Roots are the solution to this problem.
Roots define the boundaries of where a server is allowed to operate. They act as a “sandbox,” telling the server: “You can go berserk inside this specific directory, but you cannot touch anything outside of it.”
The Concept of Roots
Roots serve two main purposes: Context Scoping and Security Boundaries.
1. Context Scoping (The “Where”)
Roots tell the server which directories are currently relevant to the user.
- If you open a project in VS Code, the IDE tells the MCP server: “The root is
/Users/me/Code/ProjectA.” - If you switch projects, the IDE sends a notification: “The root is now
/Users/me/Code/ProjectB.”
This ensures the LLM focuses only on the files that matter for the current task.
2. Security Boundaries (The “Fence”)
This is the most critical aspect.
-
Allowed Root:
file:///Users/me/safe_zone -
Forbidden Area:
file:///Users/me/secrets
If the LLM decides to run a tool like delete_file("/Users/me/safe_zone/temp.txt"), the server allows it.
However, if the LLM tries to run read_file("/Users/me/secrets/passwords.txt"), the server blocks it immediately.
Implementing a Secure Server in Python
To understand how to enforce this, you will build a Secure File Reader.
This server will not rely on hardcoded paths. Instead, it will use the MCP protocol to ask the client which directories are allowed.
Create a file named secure_server.py:
from pathlib import Path
from mcp.server.fastmcp import FastMCP, Context
import os
mcp = FastMCP("Secure-FS")
def extract_path_from_uri(uri) -> Path:
"""Extract file path from URI, handling both string URIs and FileUrl objects."""
uri_str = str(uri)
if uri_str.startswith('file://'):
path_part = uri_str[7:]
else:
path_part = uri_str
if os.name == 'nt' and path_part.startswith('/') and len(path_part) > 1 and path_part[1] != '/':
path_part = path_part[1:]
return Path(path_part)
@mcp.tool()
async def read_secure_file(path: str, ctx: Context) -> str:
"""
Reads a file ONLY if it is inside the client-provided root directories.
Respects MCP roots as advisory boundaries for file access.
"""
try:
roots_list = await ctx.session.list_roots()
if not roots_list.roots:
fallback_sandbox = Path.cwd() / "sandbox"
allowed_dirs = [fallback_sandbox.resolve()]
else:
allowed_dirs = []
for root in roots_list.roots:
try:
root_path = extract_path_from_uri(root.uri)
allowed_dirs.append(root_path.resolve())
except Exception as e:
continue
if not allowed_dirs:
fallback_sandbox = Path.cwd() / "sandbox"
allowed_dirs = [fallback_sandbox.resolve()]
except Exception as e:
fallback_sandbox = Path.cwd() / "sandbox"
allowed_dirs = [fallback_sandbox.resolve()]
try:
target_path = Path(path).resolve()
except Exception as e:
return f"Error: Invalid path structure: {e}"
is_allowed = False
for root in allowed_dirs:
if target_path.is_relative_to(root):
is_allowed = True
break
if not is_allowed:
if len(allowed_dirs) == 1 and allowed_dirs[0].name == "sandbox":
sandbox_path = Path.cwd() / "sandbox"
return f"ACCESS DENIED: '{path}' is outside the allowed sandbox ({sandbox_path})."
else:
roots_str = ", ".join(str(d) for d in allowed_dirs)
return f"ACCESS DENIED: '{path}' is outside the allowed directories ({roots_str})."
try:
if not target_path.exists():
return "Error: File not found."
return target_path.read_text(encoding='utf-8')
except Exception as e:
return f"Error reading file: {e}"
if __name__ == "__main__":
mcp.run(transport="streamable-http")
What You Are Building
You are building a server that only reads files inside client-approved directories. The client defines the allowed roots, and the server enforces them for every file request.
Key Security Logic
-
ctx.session.list_roots(): The server dynamically queries the client for permission. -
extract_path_from_uri: MCP usesfile://URIs (like browsers do), but Python uses OS paths. This helper bridges the gap. -
target_path.is_relative_to(root): This is the firewall. It ensures the requested file is physically located inside one of the authorized folders.
How the Server Script Works
-
ctx.session.list_roots()asks the client which directories are allowed. -
extract_path_from_uri(...)convertsfile://URIs into OS paths you can compare. -
is_relative_to(...)enforces the boundary check for every request. - If no roots are provided, the server falls back to a local
sandboxdirectory.
Implementing the Client with Roots
Now, create the client. This client represents the “Host Application” (like VS Code). It acts as the authority, telling the server: “You are only allowed to touch the sandbox folder.”
Create a file named root_client.py:
import asyncio
from pathlib import Path
from mcp import ClientSession, types
from mcp.client.streamable_http import streamablehttp_client
SANDBOX_DIR = Path.cwd() / "sandbox"
SANDBOX_DIR.mkdir(exist_ok=True)
SECRET_FILE = Path.cwd() / "secret.txt"
SECRET_FILE.write_text("This is top secret data!")
SAFE_FILE = SANDBOX_DIR / "hello.txt"
SAFE_FILE.write_text("This is safe to read.")
SERVER_URL = "http://127.0.0.1:8000/mcp"
async def list_roots_handler(context) -> types.ListRootsResult:
"""
Provide MCP roots to define filesystem boundaries for the server.
This tells the server which directories it should operate within.
"""
return types.ListRootsResult(
roots=[
types.Root(
uri=f"file://{SANDBOX_DIR.as_posix()}",
name="Safe Sandbox"
)
]
)
async def run_client():
print(f"Connecting to server at {SERVER_URL}...")
async with streamablehttp_client(SERVER_URL) as (read, write, _):
async with ClientSession(
read,
write,
list_roots_callback=list_roots_handler
) as session:
await session.initialize()
print("Connected! (Roots provided)\n")
print(f"--- Test 1: Reading Safe File ---")
print(f"Requesting: {SAFE_FILE}")
result_safe = await session.call_tool(
"read_secure_file",
arguments={"path": str(SAFE_FILE)}
)
print(f"Result: {result_safe.content[0].text}\n")
print(f"--- Test 2: Attempting Jailbreak ---")
print(f"Requesting: {SECRET_FILE}")
result_jailbreak = await session.call_tool(
"read_secure_file",
arguments={"path": str(SECRET_FILE)}
)
print(f"Result: {result_jailbreak.content[0].text}\n")
if __name__ == "__main__":
asyncio.run(run_client())
In this architecture:
-
The Client dictates the rules via
list_roots_handler. It sends a specificfile://URI to the server. -
The Server asks for these rules and enforces them using
is_relative_to.
Even though the Python script physically could read the secret file (because the script runs with your user permissions), the read_secure_file tool acts as a gatekeeper. It sees that secret.txt is not inside the sandbox root provided by the client, and it blocks the access.
This makes MCP servers safe to run on your local machine. In the next video, you will see this security boundary in action.
How the Client Script Works
-
list_roots_handler(...)is the callback the server asks for when it needs roots. - It returns a list of
file://URIs that define the allowed directories. -
list_roots_callback=...is what makes the client “roots-aware.”
Run It
- In one terminal, start the server:
uv run python secure_server.py
- In a second terminal, run the client:
uv run python root_client.py
You should see the safe file read succeed and the secret file read blocked. Note that MCP uses file:// URIs, which look different from normal Windows paths, so the conversion helper is required.