AI
MCP Roots is deprecated: migrate file scope without expanding access
A production runbook for replacing MCP Roots with tool parameters, resource URIs or server configuration while preserving path controls, canary evidence and rollback.
A developer assistant uses an MCP server to search files, read manifests and prepare a patch in the repository opened by the user. After an SDK upgrade, logs warn that MCP Roots is deprecated. The team wants to migrate quickly, but replacing the workspace root with a free-form path parameter would turn a protocol upgrade into broader filesystem access.
Roots allowed a client to tell a server which file URIs were relevant to a session. Recent protocol versions deprecate the feature in favor of tool parameters, resource URIs or server configuration. This use case is an internal tool that can read and propose changes, but must never explore the host freely. The runbook decides whether to migrate, keep a temporary compatibility path or roll the upgrade back.
Freeze the file contract before changing the protocol
Start from one healthy session and one candidate session. Preserve the negotiated protocol version, client and server capabilities, the roots/list response where it still exists, every tool that consumes it and the files actually accessed.
migration_id: mcp-filescope-20260910
client: developer-assistant
server: repository-tools
current_protocol: <current-negotiated-version>
candidate_protocol: <candidate-negotiated-version>
current_scope:
roots:
- file:///workspace/orders-api
allowed_operations: [search, read, propose_patch]
forbidden_operations: [read_home, read_credentials, write_directly]
evidence:
- initialize_and_capabilities
- roots_list_response
- tool_calls_using_roots
- canonical_paths_accessed
- authorization_decisions
- patch_diff
temporary_controls:
direct_write: disabled
candidate_mode: read_only
fallback_server_release: repository-tools-41 A friendly root name is not an authorization identifier. Compare the observed URI, resolved canonical path and identity that opens the file.
Do not confuse context with a security boundary
Roots helped a server understand the relevant workspace. That hint did not replace process permissions, container isolation or server-side path validation. A safe migration therefore preserves two independent boundaries:
- functional selection identifies the project the tool should operate on;
- technical authorization prevents the process from leaving the allowed scope.
A server with the whole volume mounted is not safe because a client advertises one root. Conversely, an explicit tool parameter remains unsafe when ../, a symbolic link or a case-handling difference can escape the approved directory.
Choose the replacement by use case
Migration is not a matter of copying the Root URI into every call. Pick the narrowest mechanism:
Tool parameter
For a target required by one exact operation
Prefer project_id and relative_path over a free absolute path
The server maps project_id to an approved root
Resource URI or resource template
For a discoverable read-only corpus
Keep the URI opaque to the model and resolve it on the server
Preserve authorization, size limits and content-type controls
Server configuration
For a fixed workspace mounted and owned by the service
Deployment supplies the root, not the prompt
Keep a separate allowlist for each environment
Reject
An absolute path selected by the model
A root inferred from the current working directory
A variable shared across tenants
A fallback to the parent directory For a multi-repository assistant, a logical identifier is usually more operable than a model-supplied path. The server controls its mapping, lifetime and the identity authorized for each project.
Build a closed path resolver
Centralize resolution before any read. search_files, read_file and propose_patch must use the same control, otherwise a secondary tool can bypass the primary boundary.
import path from "node:path";
import fs from "node:fs/promises";
export async function resolveProjectPath(
approvedRoot: string,
relativePath: string
) {
if (path.isAbsolute(relativePath)) throw new Error("absolute_path_denied");
const root = await fs.realpath(approvedRoot);
const candidate = await fs.realpath(path.resolve(root, relativePath));
const prefix = root.endsWith(path.sep) ? root : root + path.sep;
if (candidate !== root && !candidate.startsWith(prefix)) {
throw new Error("path_outside_project");
}
return candidate;
} Complete this baseline for the target platform: case handling, symlinks created after validation, mounts, special files, maximum size and allowed extensions. For writes, use file-opening primitives that reduce check-to-use races, then validate the canonical parent again.
Make scope visible in the tool schema
The input contract should expose the project and a relative path. It should not let the model invent a root.
{
"type": "object",
"additionalProperties": false,
"required": ["project_id", "relative_path"],
"properties": {
"project_id": {
"type": "string",
"enum": ["orders-api-canary"]
},
"relative_path": {
"type": "string",
"minLength": 1,
"pattern": "^(?!/)(?!.*(?:^|/)\.\.(?:/|$)).+$"
}
}
} The pattern improves error feedback, but the canonical resolver remains the security decision. A schema-valid string can still reach a symlink that leaves the project.
Compare old and new scope in shadow mode
During transition, evaluate the Roots-based scope and the new mapping for the same read calls. Return only one result to the agent and log differences in selected files.
cases:
- id: read_known_manifest
project_id: orders-api-canary
relative_path: package.json
expect: allowed_and_same_file
- id: block_parent_traversal
relative_path: ../platform/secrets.env
expect: denied_before_open
- id: block_encoded_traversal
relative_path: docs/%2e%2e/%2e%2e/secret
expect: denied_after_decode_once
- id: block_symlink_escape
relative_path: tmp/external-link/token
expect: denied_after_realpath
- id: reject_unknown_project
project_id: finance-prod
expect: denied_without_scope_discovery
- id: preserve_patch_boundary
relative_path: src/config.ts
expect: patch_contains_only_approved_project Include a workspace switch during the session. The older client could notify the server when Roots changed; the replacement contract must invalidate the previous project_id or start a new session. An agent must not retain scope from the repository that was open before.
Trace the decision, not sensitive content
Log identity, session, protocol version, tool, project_id, approved-root digest, relative path, canonicalization result and decision. Keep file contents and discovered secrets out of telemetry.
let Window = 24h;
McpFileScopeEvents
| where TimeGenerated > ago(Window)
| project TimeGenerated,
AgentName,
SessionId,
ProtocolVersion,
ToolName,
ProjectId,
ApprovedRootDigest,
RelativePath,
CanonicalizationResult,
Decision,
Reason,
CorrelationId
| order by TimeGenerated desc Alert on denied traversal, unknown projects, old-versus-new scope differences and any write call without a correlated authorization decision.
Decide migration, temporary support or rollback
Migrate when every Roots dependency has an explicit replacement, canonical paths match in canary, negative cases are denied and the execution identity cannot read outside scope.
Keep the older negotiated version temporarily when a client still depends on Roots, with a removal date, consumer inventory and writes disabled for unmigrated clients. Compatibility must not become a silent fallback.
Roll back the candidate client or server when the new mapping selects more files, a workspace switch retains old authorization, a symlink escape succeeds or decisions cannot be traced. The correct rollback restores the known version while preserving process isolation; it never opens a parent directory to recover service.
Conclusion
The deprecation of MCP Roots forces an implicit contract into the open: which project, path and identity may participate in an agent operation.
Replace Roots with a project identifier and relative path, a resource URI or server configuration according to the use case. Then validate canonical paths, real isolation, refusal cases and workspace changes. Migration is complete when the agent remains useful with a more provable scope, not when an SDK warning disappears.