2026-07-16

MCP Roots: Enforce Safe Workspace Boundaries

Enforce MCP roots with capability negotiation, canonical file-URI containment, root-change handling, symlink defenses, and adversarial tests.

Short answer: MCP roots let a client tell a server which filesystem locations belong to the active workspace. Treat that list as authorization input, not a hint: advertise the capability, fetch roots, canonicalize every file:// URI, authorize every read and write against the current canonical roots, refresh after notifications/roots/list_changed, and deny the operation when the boundary is missing or ambiguous.

A filesystem MCP server can be technically correct and still expose too much. Starting it from a repository does not prove that every path passed to a tool stays in that repository. Relative traversal, absolute paths, symlinks, case differences, stale workspace caches, and multi-root projects can all turn a convenient file tool into access to unrelated source code, credentials, or home-directory data.

The MCP roots capability gives the client and server a standard boundary exchange. The client remains the authority on which workspaces are active; the server remains responsible for enforcing that boundary on every operation. This guide implements both halves and adds failure-first tests so a green check means more than “the happy path opened a file.”

Important: roots are not an operating-system sandbox. A malicious or compromised server process may ignore the protocol. Run untrusted servers with process isolation and narrow OS permissions as well. Roots are the application-layer boundary for a cooperating server.

What MCP roots define

In the current MCP specification, a supporting client declares a roots capability during initialization. If the client can notify the server when workspaces change, it sets listChanged: true. A server asks for the current list with roots/list. Each root has a required file:// URI and an optional display name.

Client responsibility

Expose only user-approved, accessible workspaces; send a change notification when the approved set changes.

Server responsibility

Check capability support, fetch roots, validate URIs, and enforce the latest boundary for every filesystem operation.

The protocol does not say that a root automatically grants every action. Your server can allow reads but deny writes, allow one tool in all roots but another in a scratch root, or require an approval for deletion. Roots answer “where may this server operate?” Tool policy still answers “what may it do there?”

Step 1: negotiate and list roots

The client advertises only behavior it actually implements. Do not set listChanged: true if workspace changes will remain invisible until reconnect.

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "roots": { "listChanged": true }
    },
    "clientInfo": { "name": "workspace-host", "version": "1.0.0" }
  }
}

When the server needs the boundary, it sends roots/list. A two-repository workspace can return both roots without pretending that their common parent is approved.

{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "roots/list"
}

{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "roots": [
      { "uri": "file:///work/acme/frontend", "name": "Frontend" },
      { "uri": "file:///work/acme/backend", "name": "Backend" }
    ]
  }
}

Fail closed: if the client did not negotiate roots, roots/list fails, the response is empty, or every URI is invalid, disable workspace file tools for that connection. Falling back to the process working directory silently widens access.

Editorial illustration of an AI application exposing two approved project workspaces to an MCP filesystem server through one controlled boundary
The client selects the approved workspaces; the server receives the current boundary and applies it before file tools run.

Step 2: canonicalize roots before storing them

String-prefix checks are unsafe. /work/acme/app-copy starts with /work/acme/app, percent-encoded separators can change meaning after decoding, and a symlink inside an approved directory can point outside it. Convert each URI to a platform path, reject unexpected URI parts, resolve the real path, and store the canonical directory identity.

import { realpath } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";

export async function canonicalRoot(uriText: string): Promise<string> {
  const uri = new URL(uriText);
  if (uri.protocol !== "file:" || uri.username || uri.password || uri.search || uri.hash) {
    throw new Error("Unsupported MCP root URI");
  }

  const diskPath = fileURLToPath(uri);
  const resolved = await realpath(diskPath);
  if (!path.isAbsolute(resolved)) throw new Error("Root is not absolute");
  return stripTrailingSeparator(resolved);
}

function stripTrailingSeparator(value: string): string {
  const parsed = path.parse(value);
  return value === parsed.root ? value : value.replace(/[\\/]+$/, "");
}

Also verify that the target is an accessible directory and record the device/inode pair where the platform provides it. Re-resolve roots after a change notification and after filesystem events that may replace the directory. A stored string is not proof that the same directory still exists.

Step 3: authorize every existing path

For a read, resolve the candidate with realpath first. Then compare path components with each canonical root. The relative path is inside only when it is empty or does not begin with .. and is not absolute.

export function isWithin(candidate: string, root: string): boolean {
  const relative = path.relative(root, candidate);
  return relative === "" ||
    (!relative.startsWith(`..${path.sep}`) &&
     relative !== ".." &&
     !path.isAbsolute(relative));
}

export async function authorizeExistingPath(
  requested: string,
  roots: readonly string[],
): Promise<string> {
  if (!path.isAbsolute(requested)) throw new Error("Absolute path required");
  const candidate = await realpath(requested);
  if (!roots.some((root) => isWithin(candidate, root))) {
    throw new Error("Path is outside negotiated MCP roots");
  }
  return candidate;
}

On case-insensitive filesystems, use the platform's canonical representation rather than lowercasing arbitrary Unicode yourself. On Windows, test drive letters, UNC paths, and separator variants explicitly. Reject non-file URI schemes even if a generic URL library can parse them.

Step 4: secure creates and writes

A new file has no real path yet. Resolve its nearest existing parent, prove that parent is inside an approved root, then create the final component without following a replacement symlink. A check followed by a normal open has a time-of-check/time-of-use window.

The strongest implementation uses directory handles and platform primitives such as openat with no-follow semantics. If your runtime does not expose those safely, constrain writes to a server-owned scratch directory inside a root, reject symlinks in every existing component, create with exclusive flags, and revalidate the opened file descriptor before committing data.

OperationBoundary checkAdditional policy
Read fileRealpath target inside current rootSize/type limit; deny devices and sockets
List directoryRealpath directory inside current rootEntry and depth limits
Create fileCanonical existing parent inside rootExclusive create; no-follow; extension and size policy
RenameSource and destination parent inside rootsDecide whether cross-root moves are allowed
DeleteRealpath target inside rootSeparate scope or explicit approval
Editorial illustration of traversal and symlink paths being stopped at a protected project workspace boundary while valid paths continue inside
Canonical containment must reject traversal, look-alike prefixes, symlink escapes, and stale roots—not only obvious absolute paths.

Step 5: handle roots/list_changed without stale access

When the approved workspace set changes, a capable client sends this notification:

{
  "jsonrpc": "2.0",
  "method": "notifications/roots/list_changed"
}

The notification does not contain the new list. Mark the cache stale, request roots/list, validate the full response, and atomically replace the old canonical set. While refresh is in flight, fail closed or finish only operations already bound to an immutable authorization snapshot. Do not mix a path validated under one version with a write committed under another.

type RootSnapshot = {
  version: number;
  paths: readonly string[];
};

let current: RootSnapshot = { version: 0, paths: [] };

async function refreshRoots(): Promise<void> {
  const response = await mcpRequest("roots/list", {});
  const next = await validateAndCanonicalize(response.result.roots);
  current = Object.freeze({ version: current.version + 1, paths: next });
}

Queue repeated notifications behind one refresh and set a short deadline. If refresh fails, do not keep using a root that the user may have removed. Record the snapshot version with each authorization decision so an incident review can reconstruct which boundary was active.

Failure-first test matrix

Create a temporary fixture with two approved roots, one unapproved sibling, a symlink from an approved root to the sibling, and a client that can remove a root during a run. The tests should prove denial before you count a successful read as evidence.

CaseExpected result
Normal file inside root AAllowed with snapshot version recorded
../private/secret.txtDenied after canonical resolution
Absolute path in an unapproved siblingDenied
Look-alike prefix such as app-copyDenied by component-aware comparison
Symlink inside root pointing outsideDenied after realpath
Percent-encoded traversal in file URIRejected during URI validation
Create below a symlinked parentDenied without partial file
Root removed during a long operationNo new operations under removed root
roots/list timeout or invalid responseWorkspace tools unavailable; no working-directory fallback
Rename from root A to root BAllowed only if explicit cross-root policy permits it

Common implementation mistakes

Using startsWith for containment

Text prefixes do not understand path components, separators, case rules, encodings, or symlinks. Canonicalize first and use a component-aware relative-path test.

Treating roots as discovery metadata

Displaying roots in a UI while tools continue accepting arbitrary absolute paths provides no security value. The current root snapshot must sit on the authorization path for every filesystem handler.

Keeping removed roots until reconnect

If listChanged is negotiated, a workspace removal should revoke new operations promptly. Make cache replacement atomic and test failed refresh behavior.

Checking reads but not writes and renames

Create, rename, archive extraction, patch application, and delete have different path-resolution hazards. Centralize policy, but keep operation-specific checks.

Logging denied paths verbatim

An unapproved path may itself contain a username, customer name, or secret fragment. Log a reason code, tool, root snapshot version, and a keyed path fingerprint rather than the full rejected path.

Production rollout sequence

  1. Inventory: list every filesystem tool and whether it reads, creates, renames, or deletes.
  2. Negotiate: advertise roots only in clients that can return and update a validated list.
  3. Canonicalize: convert file URIs to real directories and reject ambiguous inputs.
  4. Enforce: place the root snapshot in the common authorization path for all file operations.
  5. Attack: run traversal, prefix, symlink, stale-cache, race, and cross-root tests.
  6. Observe: record bounded denial reasons and root snapshot versions without raw rejected paths.
  7. Layer: add OS sandboxing and process permissions for servers that should not be trusted to self-enforce.

MCP roots production checklist

  • The client advertises roots only when it implements roots/list.
  • listChanged is true only when workspace changes produce notifications.
  • Every root URI uses file:// and resolves to an accessible directory.
  • Canonical roots are stored as an atomic, versioned snapshot.
  • Every file operation authorizes against the current snapshot.
  • Containment uses canonical paths and path components, not string prefixes.
  • Reads reject traversal, look-alike prefixes, and symlink escapes.
  • Writes validate the existing parent and prevent symlink races.
  • Refresh failure disables workspace tools instead of widening access.
  • Cross-root rename and delete have explicit policies.
  • Denied-path logs omit raw sensitive paths.
  • The server process also has narrow OS permissions or sandboxing.

Continue with the security stack

Use MCP OAuth authorization for remote identity and scopes, then add AI agent sandbox security with gVisor when the server process itself is not trusted. For a managed agent worker with isolated browser, terminal, files, memory, schedules, and scoped secrets, create a GolemWorkers AI agent and keep the same root tests in the deployment gate.

Sources