MCP Roots Protocol Constraints: Security and Validation Rules Explained

The MCP Roots protocol constraints enforce that client-provided directory lists completely replace server-side allow lists, require at least one valid directory to exist, validate that all root URIs resolve to real filesystem paths, and support dynamic updates through standardized notifications.

The Model Context Protocol (MCP) Roots capability enables clients to specify which filesystem locations a server may access. According to the modelcontextprotocol/servers reference implementation, strict MCP Roots protocol constraints ensure that only verified, existing directories are exposed and that the security boundary remains deterministic across client sessions.

How Roots Replace Server-Side Allow Lists

When a client connects with Roots capabilities, the server completely overwrites any directories supplied via command-line arguments. In src/filesystem/index.ts, the updateAllowedDirectoriesFromRoots() function handles this atomic replacement (lines 730–749):

// The server calls this during initialization
await updateAllowedDirectoriesFromRoots(response.roots);
// allowedDirectories is replaced entirely, not merged

This design ensures the client has explicit, predictable control over the filesystem view. The server never merges or accumulates directories from multiple sources—it uses exactly what the client specifies.

Mandatory Directory Requirements

The server enforces a hard requirement: at least one allowed directory must exist. If neither command-line arguments nor a non-empty roots list are present, the server aborts immediately with a clear error (lines 749–750 in src/filesystem/index.ts):

if (allowedDirectories.length === 0) {
  throw new Error("No allowed directories provided");
}

Even when the client supports roots but sends an empty list, the server logs a warning and retains the previous configuration, though it still requires at least one valid directory to remain operational (lines 40–42).

URI Validation and Path Resolution

All root URIs undergo strict validation in getValidRootDirectories() (src/filesystem/roots-utils.ts, lines 13–23 and 52–68). The server:

  1. Parses the URI through parseRootUri() (lines 13–22)
  2. Expands and resolves symlinks using fs.realpath()
  3. Normalizes paths via normalizePath (lines 20–22) to handle platform-specific quirks like macOS /tmp versus /private/tmp
  4. Verifies the target is a directory using fs.stat()

This prevents clients from specifying non-existent paths, files instead of directories, or paths that bypass security through symlink manipulation.

Client Capability Detection

The server only attempts to fetch roots when the client explicitly advertises the capability. During the oninitialized phase (src/filesystem/index.ts, lines 33–45), the server checks:

if (clientCapabilities?.roots) {
  const response = await server.server.listRoots();
  // Process roots only if client supports them
}

If the client does not advertise roots capability, the server falls back to command-line supplied directories, maintaining backward compatibility while preserving security constraints.

Dynamic Root Updates

Clients can modify the filesystem access list at runtime by sending a roots/list_changed notification. The handler at lines 17–28 in src/filesystem/index.ts immediately re-requests the full list:

{
  "method": "notification/roots/list_changed"
}

The server then repeats the validation cycle through updateAllowedDirectoriesFromRoots(), ensuring dynamic updates meet the same security standards as the initial configuration.

Supported URI Schemes

The protocol restricts root specifications to local filesystem resources only. The parseRootUri() function (src/filesystem/roots-utils.ts, lines 13–22) accepts:

  • file:// URIs (e.g., file:///Users/alice/projects)
  • Plain absolute or relative paths (e.g., /var/data or ./projects)

Any URI scheme that cannot be resolved to a real filesystem path is rejected during the validation phase.

Practical Configuration Examples

Starting with command-line directories (fallback mode):


# Provide directories directly when client lacks Roots support

mcp-server-filesystem /Users/alice/projects /var/data

Client-specified roots during initialization:

{
  "capabilities": {
    "roots": {}
  },
  "roots": [
    { "uri": "file:///Users/alice/projects" },
    { "uri": "file:///var/data" }
  ]
}

Querying current allowed directories:


# Using the built-in tool to verify active constraints

mcp> list_allowed_directories

Output:


Allowed directories:
/Users/alice/projects
/var/data

Summary

  • Atomic replacement: Client roots overwrite command-line directories completely via updateAllowedDirectoriesFromRoots() in src/filesystem/index.ts (lines 730–749).
  • Non-empty requirement: The server refuses to start without at least one valid directory (lines 749–750).
  • Real path validation: getValidRootDirectories() ensures all roots exist, resolve symlinks, and normalize paths (src/filesystem/roots-utils.ts).
  • Capability-gated: The server checks clientCapabilities?.roots before fetching any directory lists (lines 33–45).
  • Dynamic updates: Runtime changes are supported through roots/list_changed notifications (lines 17–28).
  • Scheme restrictions: Only file:// URIs and plain paths are accepted by parseRootUri().

Frequently Asked Questions

What happens if a client sends an empty roots list?

If the client supports roots but provides an empty array, the server logs a warning (lines 40–42 in src/filesystem/index.ts) and retains the current configuration. However, if this results in zero allowed directories total, the server will throw an error and refuse to operate, maintaining the mandatory directory constraint.

Can a server merge client roots with command-line directories?

No. According to the source code in src/filesystem/index.ts (lines 730–749), the server assigns the returned list directly to allowedDirectories, completely replacing any directories supplied via command-line arguments. This ensures deterministic security boundaries where the client has explicit control.

The server resolves all symbolic links using fs.realpath() before normalization (src/filesystem/roots-utils.ts, lines 20–22). This prevents path traversal attacks where a symlink might point outside the intended directory, and ensures that different textual representations of the same directory (like /tmp versus /private/tmp on macOS) are treated equivalently.

What URI schemes are supported for MCP roots?

Only file:// URIs and plain absolute or relative filesystem paths are accepted. The parseRootUri() function in src/filesystem/roots-utils.ts (lines 13–22) explicitly rejects any URI scheme that cannot be converted to a real filesystem path, ensuring the server only accesses local directories.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →