Skip to content

feat(mcpserver): add Context.assert_within_roots for server-side roots enforcement#2468

Open
NeelakandanNC wants to merge 2 commits intomodelcontextprotocol:mainfrom
NeelakandanNC:feat/roots-utility
Open

feat(mcpserver): add Context.assert_within_roots for server-side roots enforcement#2468
NeelakandanNC wants to merge 2 commits intomodelcontextprotocol:mainfrom
NeelakandanNC:feat/roots-utility

Conversation

@NeelakandanNC
Copy link
Copy Markdown

Closes #2453.

Adds assert_within_roots(path) as an async method on the Context class, so any FastMCP/MCPServer tool can enforce that a user-provided path stays within the filesystem boundaries the client declared via the Roots capability.

@mcp.tool()
async def read_file(path: str, ctx: Context) -> str:
    await ctx.assert_within_roots(path)  # raises PermissionError if outside roots
    with open(path) as f:
        return f.read()

Motivation and Context

MCP clients can declare roots, but the SDK never enforces them server-side. Any tool can open any path regardless of what the client declared — the check is left entirely to the tool author. This is a real security gap for filesystem-exposing servers and easy to forget.

The reference @modelcontextprotocol/server-filesystem has enforcement baked in, but anyone building a custom FastMCP/MCPServer doesn't get that for free.

On the API design — responding to feedback on #2425: an earlier attempt at this was closed with the feedback that the proposed API (a @within_roots_check decorator in a separate utility module) was "not easy to use." This PR takes a different approach based on that feedback:

  • No decorator, no magic — the check is an explicit line at the top of the tool body
  • Method lives on Context alongside ctx.report_progress, ctx.read_resource, ctx.elicit, so it's discoverable by anyone already writing tools that take a Context
  • Single call site, clear failure mode — raises PermissionError with the offending path, no configuration, no opt-in

Behavior:

  • Resolves the target path with pathlib.Path.resolve() — symlinks and relative segments are normalized before comparison
  • Iterates declared roots via self.request_context.session.list_roots() and accepts the path if it falls within any of them
  • Raises PermissionError if the path is outside every declared root, or if no roots are declared (fail-closed)
  • Uses urllib.request.url2pathname to convert file:// URIs — works on both POSIX and Windows

How Has This Been Tested?

Five new tests in tests/server/mcpserver/test_roots.py, exercising the real client/server path (no mocks on list_roots):

  • Path inside a declared root passes
  • Path outside all declared roots raises
  • Empty roots list raises
  • Symlink inside a root pointing outside raises (validates .resolve() behavior)
  • Path inside any one of multiple declared roots passes

All five pass locally. Full suite (pytest tests/) is also green — 1166 passed, 98 skipped, 1 xfailed (existing state, unchanged by this PR).

Breaking Changes

None. This is a purely additive change — a new method on an existing class. Existing tools are unaffected; the check is opt-in per tool.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed (docstring on the new method with usage example)

Additional context

The docstring on assert_within_roots includes a full usage example that matches the patterns already used in report_progress, read_resource, etc., so the method is self-documenting for anyone using hover tooltips or generated API docs.

…s enforcement

MCP clients declare filesystem boundaries via the Roots capability, but the SDK has never enforced them server-side. Any tool could access any path regardless of declared roots — a security gap addressed by this change.

Adds assert_within_roots(path) as an async method on Context. Developers call it at the start of any tool accepting a user-provided path; it raises PermissionError if the path is outside every declared root, or if no roots are declared.

Github-Issue: modelcontextprotocol#2453
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Add roots enforcement utility to FastMCP (get_roots, assert_within_roots, @within_roots_check)

1 participant