Skip to content

Commit 0a35fd8

Browse files
committed
Stabilize directory resource listings
1 parent 91941ed commit 0a35fd8

3 files changed

Lines changed: 75 additions & 5 deletions

File tree

‎docs/servers/resources.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ The same rule applies to anything else JSON-serialisable: a list, a Pydantic mod
126126
`mcp.server.mcpserver.resources` has ready-made `Resource` classes (`TextResource`,
127127
`BinaryResource`, `FileResource`, `HttpResource`, `DirectoryResource`) that you register
128128
with `mcp.add_resource(...)`.
129+
`DirectoryResource` returns sorted relative file paths with `/` separators on every platform.
129130

130131
A client can also **subscribe** to a resource and be notified when it changes; that's the client's half of the story and it lives in **[The Client](../client/index.md)**.
131132

‎src/mcp/server/mcpserver/resources/types.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -206,13 +206,13 @@ class DirectoryResource(Resource):
206206

207207
@pydantic.field_validator("path")
208208
@classmethod
209-
def validate_absolute_path(cls, path: Path) -> Path: # pragma: no cover
209+
def validate_absolute_path(cls, path: Path) -> Path:
210210
"""Ensure path is absolute."""
211211
if not path.is_absolute():
212212
raise ValueError("Path must be absolute")
213213
return path
214214

215-
def list_files(self) -> list[Path]: # pragma: no cover
215+
def list_files(self) -> list[Path]:
216216
"""List files in the directory."""
217217
if not self.path.exists():
218218
raise FileNotFoundError(f"Directory not found: {self.path}")
@@ -223,8 +223,9 @@ def list_files(self) -> list[Path]: # pragma: no cover
223223
return list(self.path.glob(self.pattern)) if not self.recursive else list(self.path.rglob(self.pattern))
224224
return list(self.path.glob("*")) if not self.recursive else list(self.path.rglob("*"))
225225

226-
async def read(self) -> str: # Always returns JSON string # pragma: no cover
226+
async def read(self) -> str:
227227
"""Read the directory listing."""
228-
files = await anyio.to_thread.run_sync(self.list_files)
229-
file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
228+
file_list = await anyio.to_thread.run_sync(
229+
lambda: sorted(f.relative_to(self.path).as_posix() for f in self.list_files() if f.is_file())
230+
)
230231
return json.dumps({"files": file_list}, indent=2)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import json
2+
from pathlib import Path
3+
4+
import pytest
5+
from pydantic import ValidationError
6+
7+
from mcp.server.mcpserver.resources import DirectoryResource
8+
9+
10+
@pytest.fixture
11+
def directory_with_files(tmp_path: Path) -> Path:
12+
(tmp_path / "z.txt").write_text("z", encoding="utf-8")
13+
(tmp_path / "a.txt").write_text("a", encoding="utf-8")
14+
(tmp_path / "nested").mkdir()
15+
(tmp_path / "nested" / "b.txt").write_text("b", encoding="utf-8")
16+
return tmp_path
17+
18+
19+
@pytest.fixture
20+
def regular_file(tmp_path: Path) -> Path:
21+
path = tmp_path / "file.txt"
22+
path.write_text("content", encoding="utf-8")
23+
return path
24+
25+
26+
@pytest.mark.anyio
27+
@pytest.mark.parametrize(
28+
("recursive", "pattern", "expected"),
29+
[
30+
(False, None, ["a.txt", "z.txt"]),
31+
(True, None, ["a.txt", "nested/b.txt", "z.txt"]),
32+
(False, "*.txt", ["a.txt", "z.txt"]),
33+
(True, "*.txt", ["a.txt", "nested/b.txt", "z.txt"]),
34+
],
35+
)
36+
async def test_directory_resource_returns_sorted_posix_file_paths(
37+
directory_with_files: Path, recursive: bool, pattern: str | None, expected: list[str]
38+
) -> None:
39+
"""SDK-defined listings contain sorted relative file paths for each glob mode."""
40+
resource = DirectoryResource(
41+
uri="test://directory", name="directory", path=directory_with_files, recursive=recursive, pattern=pattern
42+
)
43+
44+
assert json.loads(await resource.read()) == {"files": expected}
45+
46+
47+
@pytest.mark.anyio
48+
async def test_directory_resource_rejects_missing_directory(tmp_path: Path) -> None:
49+
"""SDK-defined directory reads report a missing path."""
50+
resource = DirectoryResource(uri="test://directory", name="directory", path=tmp_path / "missing")
51+
52+
with pytest.raises(FileNotFoundError):
53+
await resource.read()
54+
55+
56+
@pytest.mark.anyio
57+
async def test_directory_resource_rejects_regular_file(regular_file: Path) -> None:
58+
"""SDK-defined directory reads reject a regular file path."""
59+
resource = DirectoryResource(uri="test://directory", name="directory", path=regular_file)
60+
61+
with pytest.raises(NotADirectoryError):
62+
await resource.read()
63+
64+
65+
def test_directory_resource_requires_absolute_path() -> None:
66+
"""SDK-defined resource validation rejects relative directory paths."""
67+
with pytest.raises(ValidationError):
68+
DirectoryResource(uri="test://directory", name="directory", path=Path("relative"))

0 commit comments

Comments
 (0)