Skip to content

Commit 159b00f

Browse files
committed
feat(mcpserver): add Context.assert_within_roots for server-side roots 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: #2453
1 parent 3d7b311 commit 159b00f

File tree

2 files changed

+180
-0
lines changed

2 files changed

+180
-0
lines changed

src/mcp/server/mcpserver/context.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from __future__ import annotations
22

33
from collections.abc import Iterable
4+
from pathlib import Path
45
from typing import TYPE_CHECKING, Any, Generic
6+
from urllib.parse import urlparse
7+
from urllib.request import url2pathname
58

69
from pydantic import AnyUrl, BaseModel
710

@@ -117,6 +120,42 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent
117120
assert self._mcp_server is not None, "Context is not available outside of a request"
118121
return await self._mcp_server.read_resource(uri, self)
119122

123+
async def assert_within_roots(self, path: str | Path) -> None:
124+
"""Assert that a filesystem path is within the client's declared roots.
125+
126+
Provides server-side enforcement of the filesystem boundaries declared by
127+
the client via the Roots capability. Call this at the start of any tool
128+
that accepts a user-provided path, to prevent the tool from accessing
129+
files outside the client's declared scope.
130+
131+
Args:
132+
path: The filesystem path to validate. Accepts a string or Path.
133+
Relative paths and symlinks are resolved before comparison.
134+
135+
Raises:
136+
PermissionError: If the path is outside every declared root, or if
137+
the client has declared no roots.
138+
139+
Example:
140+
```python
141+
@server.tool()
142+
async def read_file(path: str, ctx: Context) -> str:
143+
await ctx.assert_within_roots(path)
144+
with open(path) as f:
145+
return f.read()
146+
```
147+
"""
148+
target = Path(path).resolve()
149+
150+
result = await self.request_context.session.list_roots()
151+
152+
for root in result.roots:
153+
root_path = Path(url2pathname(urlparse(str(root.uri)).path)).resolve()
154+
if target.is_relative_to(root_path):
155+
return
156+
157+
raise PermissionError(f"Path {target} is not within any declared root")
158+
120159
async def elicit(
121160
self,
122161
message: str,
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
from pydantic import FileUrl
5+
6+
from mcp import Client
7+
from mcp.client.session import ClientSession
8+
from mcp.server.mcpserver import Context, MCPServer
9+
from mcp.shared._context import RequestContext
10+
from mcp.types import ListRootsResult, Root, TextContent
11+
12+
13+
def _make_callback(roots: list[Root]):
14+
async def list_roots_callback(
15+
context: RequestContext[ClientSession],
16+
) -> ListRootsResult:
17+
return ListRootsResult(roots=roots)
18+
19+
return list_roots_callback
20+
21+
22+
@pytest.mark.anyio
23+
async def test_path_within_root_passes(tmp_path: Path):
24+
"""A path inside a declared root should not raise."""
25+
inside = tmp_path / "file.txt"
26+
inside.touch()
27+
28+
server = MCPServer("test")
29+
30+
@server.tool("check")
31+
async def check(context: Context, path: str) -> bool:
32+
await context.assert_within_roots(path)
33+
return True
34+
35+
callback = _make_callback([Root(uri=FileUrl(f"file://{tmp_path}"))])
36+
37+
async with Client(server, list_roots_callback=callback) as client:
38+
result = await client.call_tool("check", {"path": str(inside)})
39+
assert result.is_error is False
40+
41+
42+
@pytest.mark.anyio
43+
async def test_path_outside_roots_raises(tmp_path: Path):
44+
"""A path outside every declared root should raise PermissionError."""
45+
root_dir = tmp_path / "allowed"
46+
root_dir.mkdir()
47+
outside = tmp_path / "elsewhere.txt"
48+
outside.touch()
49+
50+
server = MCPServer("test")
51+
52+
@server.tool("check")
53+
async def check(context: Context, path: str) -> bool:
54+
await context.assert_within_roots(path)
55+
return True
56+
57+
callback = _make_callback([Root(uri=FileUrl(f"file://{root_dir}"))])
58+
59+
async with Client(server, list_roots_callback=callback) as client:
60+
result = await client.call_tool("check", {"path": str(outside)})
61+
assert result.is_error is True
62+
assert isinstance(result.content[0], TextContent)
63+
assert "not within any declared root" in result.content[0].text
64+
65+
66+
@pytest.mark.anyio
67+
async def test_no_roots_declared_raises(tmp_path: Path):
68+
"""An empty roots list should always raise."""
69+
target = tmp_path / "file.txt"
70+
target.touch()
71+
72+
server = MCPServer("test")
73+
74+
@server.tool("check")
75+
async def check(context: Context, path: str) -> bool:
76+
await context.assert_within_roots(path)
77+
return True
78+
79+
callback = _make_callback([])
80+
81+
async with Client(server, list_roots_callback=callback) as client:
82+
result = await client.call_tool("check", {"path": str(target)})
83+
assert result.is_error is True
84+
assert isinstance(result.content[0], TextContent)
85+
assert "not within any declared root" in result.content[0].text
86+
87+
88+
@pytest.mark.anyio
89+
async def test_symlink_escaping_root_raises(tmp_path: Path):
90+
"""A symlink inside a root that points outside should raise (resolve follows links)."""
91+
root_dir = tmp_path / "allowed"
92+
root_dir.mkdir()
93+
outside_dir = tmp_path / "forbidden"
94+
outside_dir.mkdir()
95+
outside_target = outside_dir / "secret.txt"
96+
outside_target.touch()
97+
98+
link = root_dir / "escape"
99+
link.symlink_to(outside_target)
100+
101+
server = MCPServer("test")
102+
103+
@server.tool("check")
104+
async def check(context: Context, path: str) -> bool:
105+
await context.assert_within_roots(path)
106+
return True
107+
108+
callback = _make_callback([Root(uri=FileUrl(f"file://{root_dir}"))])
109+
110+
async with Client(server, list_roots_callback=callback) as client:
111+
result = await client.call_tool("check", {"path": str(link)})
112+
assert result.is_error is True
113+
114+
115+
@pytest.mark.anyio
116+
async def test_multiple_roots_any_match_passes(tmp_path: Path):
117+
"""A path inside any one of several declared roots should pass."""
118+
root_a = tmp_path / "a"
119+
root_a.mkdir()
120+
root_b = tmp_path / "b"
121+
root_b.mkdir()
122+
target = root_b / "file.txt"
123+
target.touch()
124+
125+
server = MCPServer("test")
126+
127+
@server.tool("check")
128+
async def check(context: Context, path: str) -> bool:
129+
await context.assert_within_roots(path)
130+
return True
131+
132+
callback = _make_callback(
133+
[
134+
Root(uri=FileUrl(f"file://{root_a}")),
135+
Root(uri=FileUrl(f"file://{root_b}")),
136+
]
137+
)
138+
139+
async with Client(server, list_roots_callback=callback) as client:
140+
result = await client.call_tool("check", {"path": str(target)})
141+
assert result.is_error is False

0 commit comments

Comments
 (0)