Skip to content

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

@NeelakandanNC

Description

@NeelakandanNC

Description

Problem

FastMCP has no built-in way to enforce that file paths or resource URIs stay
within client-declared roots. The MCP spec defines a Roots capability so clients
can advertise which filesystem boundaries a server should respect — but nothing
in the SDK enforces those boundaries.

Server authors who want to honour roots today must:

  • Manually call ctx.session.list_roots() on every tool invocation
  • Write their own path normalisation and prefix-checking logic
  • Repeat this across every tool that handles file paths

This makes it easy to accidentally bypass the security intent of the Roots
capability.

Example of the gap

# Client declares roots: ["/home/user/project"]
# But a tool call arrives with an arbitrary path:

@mcp.tool()
async def read_file(path: str, ctx: Context) -> str:
    with open(path) as f:           # No enforcement.
        return f.read()              # Happily reads /etc/passwd
                                     # or ../../.ssh/id_rsa.

The client declared a boundary. The SDK exposed that boundary via ctx.session.list_roots(). But nothing checked the incoming path against it — because that check is the server author's responsibility, and the SDK ships no helper for it.

Proposed Solution

Add a utility module at src/mcp/server/fastmcp/utilities/roots.py that exposes:

get_roots(ctx) — async helper that fetches the current roots from the session.

assert_within_roots(path, roots) — validates a path is within at least one
declared root; raises PermissionError with a clear message if not.

@within_roots_check — decorator for tool functions that runs the above two
automatically, so a tool can be protected with one line.

Example

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.utilities.roots import within_roots_check

mcp = FastMCP("file-server")

@mcp.tool()
@within_roots_check
async def read_file(path: str, ctx: Context) -> str:
    with open(path) as f:
        return f.read()

If path is outside any client-declared root, the decorator raises before the
body runs.

Implementation Notes

  • Roots are fetched via ctx.session.list_roots() (the existing SDK pattern)
  • Path comparison uses pathlib.Path.resolve() to normalise symlinks and
    relative segments before prefix-checking
  • The decorator uses functools.wraps to preserve the tool function's signature
  • Follows the same pattern as other FastMCP utilities (logging, dependencies)

Checklist

  • Searched existing issues — no duplicate found
  • Implementation and tests written locally on branch feat/roots-utility
  • Will submit a PR referencing this issue

References

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions