Understanding Roots

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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.

2. Security Boundaries (The “Fence”)

This is the most critical aspect.

Implementing a Secure Server in Python

To understand how to enforce this, you will build a Secure File Reader.

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

  1. ctx.session.list_roots(): The server dynamically queries the client for permission.
  2. extract_path_from_uri: MCP uses file:// URIs (like browsers do), but Python uses OS paths. This helper bridges the gap.
  3. 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(...) converts file:// 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 sandbox directory.

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.”

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())

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

  1. In one terminal, start the server:
uv run python secure_server.py
uv run python root_client.py
See forum comments
Download course materials from Github
Previous: Handling Elicitation with a Custom Client Next: Enforcing Roots