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.

MCP Roots: Enforce Safe Workspace Boundaries cover illustration

An MCP root looks modest on the wire: a file:// URI and, optionally, a human-readable name. The security decision behind it is not modest. A client is telling a server which filesystem regions it may consider relevant, and that list can change while the server is still holding paths, plans, or queued writes.

The dangerous implementation is the one that treats roots as a startup hint. It checks whether a requested string begins with an allowed string, caches the answer, and lets the filesystem settle the details later. That design loses on sibling prefixes, .., symlinks, creates whose final file does not yet exist, renames with an unapproved destination, and a root list that was revoked after the check.

A defensible implementation treats the negotiated root set as versioned authority state. It resolves the filesystem object relevant to the operation, proves containment at the last responsible moment, and refuses work when the state is missing or stale.

Roots are still one layer. Operating-system permissions, sandboxing, authorization, and race-resistant file operations remain necessary.

Evidence boundary: this revision uses the current MCP roots specification plus a deterministic 16-case POSIX filesystem probe. The probe is not an MCP SDK conformance suite and does not measure Windows volumes, UNC paths, kernel-level race resistance, authorization identity, or production latency.

Roots are negotiated authority state

In the current MCP specification, a client that supports roots declares the roots capability during initialization. A server requests the list with roots/list. If the negotiated capability includes listChanged, the client sends notifications/roots/list_changed when the list changes, and the server asks for the list again. That sequence matters more than the JSON shape. The root list is not a server preference and it is not permanent configuration. It is client-supplied state inside a capability-negotiated session. A server that did not negotiate roots must not quietly assume them. A server that receives an empty list should not reinterpret it as the whole machine. A server that learns the list changed should invalidate decisions derived from the previous generation.

{
  "generation": 8,
  "roots": [
    {"uri": "file:///workspace/app", "name": "application"},
    {"uri": "file:///workspace/docs", "name": "documentation"}
  ]
}

The generation field above is an implementation record, not an MCP wire field. Store something equivalent beside every approved plan or queued mutation. Before execution, compare the captured generation with the current root snapshot. A mismatch is a reason to re-authorize the operation, not an invitation to hope that the old answer is still acceptable.

A prefix is not containment

String prefixes confuse spelling with location. If /workspace/app is allowed, /workspace/application-secrets shares the same characters but is not its child. A path containing ../ can begin inside and resolve outside. Encoded or platform-specific separators create more variations. OWASP and CWE-22 both describe this class of failure: external input constructs a path intended to stay below a restricted parent, yet special path elements resolve somewhere else. For an existing path, the useful question is whether its canonical filesystem location is equal to an approved root or is a descendant of one. Resolve the candidate and each configured root using the host operating system, then compare path components with platform-appropriate semantics. Do not remove suspicious characters and call the result safe. Denylists are brittle; the object reached by the path is what matters.

A protected workspace core accepts one green route while orange and red routes turn away at the filesystem boundary
The visible spelling is only a route request. Authorization belongs to the canonical object reached after filesystem resolution.

Python's Path.resolve() illustrates the distinction. It makes a path absolute, resolves symbolic links, and eliminates .. components. The PurePath classes deliberately do not collapse .. because doing so without filesystem access can be wrong when an earlier component is a symlink. A security check built only from lexical normalization can therefore disagree with the object the later file operation reaches.

Symlinks turn a plausible-looking child path into a boundary crossing. Suppose /workspace/app/cache points to /var/shared/cache. A lexical check accepts /workspace/app/cache/result.json; the write lands outside the advertised root. The inverse can be legitimate: an in-root symlink that ultimately resolves to another object under the same root need not be denied merely because a symlink exists.

The probe used two symlinks to keep these cases separate. One pointed from an allowed tree to another location inside that tree and was accepted. The other pointed outside and was denied. The important rule was not “reject every symlink.” It was “authorize the canonical target that the operation will use.”

Canonicalization is necessary, but it does not close a time-of-check/time-of-use race. An attacker or concurrent process may replace a component after validation and before open or rename. High-risk servers should use operating-system facilities that bind validation and access more tightly: directory-relative operations, handles or file descriptors anchored to an approved directory, no-follow semantics where appropriate, and least-privilege process isolation.

A root check reduces authority. It does not become a sandbox.

Validation begins at the parent

A new file has no canonical target yet. Calling a strict resolver on /workspace/app/out/new.json may fail precisely because the file is new. The authorization object is the nearest existing parent, plus the final name that will be created below it.

For a create, resolve the parent directory, prove that parent is inside an active root, reject a final component that changes the path model, and perform the create relative to the approved parent. The local probe deliberately failed closed when the immediate parent did not exist. That is a stricter local policy, not a requirement imposed by MCP. A product that supports recursive directory creation can walk and create one approved component at a time, but it must specify and test that policy rather than accidentally delegating it to a convenience API.

authorize_create(root_snapshot, requested_path):
    require snapshot_is_current(root_snapshot)
    parent = canonical_existing_parent(requested_path)
    require contained_by_active_root(parent)
    require safe_final_component(requested_path.name)
    return approved_parent_handle, requested_path.name

Writes through an outward-pointing symlink were denied in the fixture because the canonical parent was outside. An ordinary create beneath an existing in-root directory was accepted. These are small tests, but they force the code to name the authority object instead of relying on string intuition.

A rename has two boundaries

Rename and move operations are easy to under-check because they look like one action. They contain two independent path decisions. The source may be inside while the destination is outside; the destination may be inside while the source imports an unapproved object. Cross-root moves may also violate a product's isolation policy even when both roots are individually advertised. Authorize both ends against the current root snapshot. For an existing source, resolve the object. For a new destination name, resolve the destination parent. Then apply the product's explicit cross-root rule. The probe accepted an in-root rename and denied a destination outside the active roots. It did not test atomicity across filesystems, overwrite behavior, or race resistance, so the article does not promote those two results into a complete move protocol.

Failure modes after a root-list change

A roots/list_changed notification is useful only if stale work loses authority. Imagine that a server listed /workspace/private, prepared a patch there, then received a new list without that root. Executing the cached patch because it was approved five minutes earlier defeats the point of the change notification.

That is revocation in name only.

Two isolated workspace containers exchange a narrowly bounded filesystem request through a transparent protocol channel
Capability negotiation establishes the channel; a versioned root snapshot keeps later execution bound to the authority that was actually granted.

The probe assigned a generation to its root snapshot. Removing a root advanced the generation. A path under the removed root was denied, and a queued decision carrying the old generation was denied even when its path would have fit the replacement list. That second denial is intentionally conservative: it prevents an old approval from being laundered through a coincidentally similar new state.

On notification, mark the cached snapshot stale immediately, cancel or pause queued filesystem work, request roots/list, validate the returned URIs, and publish a new generation only after the list is ready. If refresh fails, the safe state is unavailable authority. Continuing indefinitely with the last good list turns a revocation mechanism into decoration.

The sixteen-case probe

The deterministic fixture created two temporary POSIX roots and two symlinks. It exercised 16 scenarios: five expected accepts and eleven expected denials. All 16 matched the policy. The accepted set covered a local file URI, an existing in-root path, an in-root symlink target, a create below an existing in-root parent, and an in-root rename.

The denied set covered a non-file URI, a remote file authority under the fixture's local-only policy, a sibling-prefix path, .. escape, outward symlink, missing create parent under the fail-closed policy, create through an outward symlink, rename to an outside destination, a removed root, a stale generation, and an empty root set.

BoundaryDecision objectFailure the probe exposed
Existing read/writeCanonical targetSibling prefixes, traversal, outward symlinks
CreateCanonical existing parent plus final componentNonexistent target and symlinked parent
RenameCanonical source plus destination parentChecking only one end
Root refreshCurrent snapshot generationQueued work retaining revoked authority

Two denials are product policy rather than protocol law. The fixture rejected file://host/path because it modeled a local-only server, and it rejected creates whose immediate parent did not exist. Another product may support remote authorities or recursive creates. If it does, those choices need platform-specific parsing, a defined trust boundary, and their own adversarial cases.

The boundary record

A practical authorization record should make later review boring. Capture the MCP session identity, negotiated roots capability, root generation, normalized root URIs, requested operation, canonical object or parent, source and destination for moves, decision, reason, and execution result. Do not log sensitive file contents. Do not treat a friendly root name as authority; it is display metadata.

Then layer the controls. MCP roots constrain the advertised workspace. Application authorization decides whether this caller may perform this operation. Operating-system permissions and a sandbox limit what the process can actually reach. Race-resistant filesystem APIs narrow the gap between check and use. Audit records explain which boundary was applied. Losing any one layer should not silently expand the server to the entire host.

The implementation test is blunt: after a path string passes validation, what exact filesystem object is authorized, under which root generation, and what prevents a different object from being used at execution? If the answer is still “the string started with the workspace path,” the boundary is not finished.


Primary material and the tested artifact