diff --git a/docs/servers/resources.md b/docs/servers/resources.md index 407f1d9ae1..9b7882dac0 100644 --- a/docs/servers/resources.md +++ b/docs/servers/resources.md @@ -126,6 +126,7 @@ The same rule applies to anything else JSON-serialisable: a list, a Pydantic mod `mcp.server.mcpserver.resources` has ready-made `Resource` classes (`TextResource`, `BinaryResource`, `FileResource`, `HttpResource`, `DirectoryResource`) that you register with `mcp.add_resource(...)`. + `DirectoryResource` returns sorted relative file paths with `/` separators on every platform. 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)**. diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py index c8b479bb78..d71dc7c10c 100644 --- a/src/mcp/server/mcpserver/resources/types.py +++ b/src/mcp/server/mcpserver/resources/types.py @@ -206,13 +206,13 @@ class DirectoryResource(Resource): @pydantic.field_validator("path") @classmethod - def validate_absolute_path(cls, path: Path) -> Path: # pragma: no cover + def validate_absolute_path(cls, path: Path) -> Path: """Ensure path is absolute.""" if not path.is_absolute(): raise ValueError("Path must be absolute") return path - def list_files(self) -> list[Path]: # pragma: no cover + def list_files(self) -> list[Path]: """List files in the directory.""" if not self.path.exists(): raise FileNotFoundError(f"Directory not found: {self.path}") @@ -223,8 +223,9 @@ def list_files(self) -> list[Path]: # pragma: no cover return list(self.path.glob(self.pattern)) if not self.recursive else list(self.path.rglob(self.pattern)) return list(self.path.glob("*")) if not self.recursive else list(self.path.rglob("*")) - async def read(self) -> str: # Always returns JSON string # pragma: no cover + async def read(self) -> str: """Read the directory listing.""" - files = await anyio.to_thread.run_sync(self.list_files) - file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()] + file_list = await anyio.to_thread.run_sync( + lambda: sorted(f.relative_to(self.path).as_posix() for f in self.list_files() if f.is_file()) + ) return json.dumps({"files": file_list}, indent=2) diff --git a/tests/server/mcpserver/resources/test_directory_resources.py b/tests/server/mcpserver/resources/test_directory_resources.py new file mode 100644 index 0000000000..263a434679 --- /dev/null +++ b/tests/server/mcpserver/resources/test_directory_resources.py @@ -0,0 +1,68 @@ +import json +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from mcp.server.mcpserver.resources import DirectoryResource + + +@pytest.fixture +def directory_with_files(tmp_path: Path) -> Path: + (tmp_path / "z.txt").write_text("z", encoding="utf-8") + (tmp_path / "a.txt").write_text("a", encoding="utf-8") + (tmp_path / "nested").mkdir() + (tmp_path / "nested" / "b.txt").write_text("b", encoding="utf-8") + return tmp_path + + +@pytest.fixture +def regular_file(tmp_path: Path) -> Path: + path = tmp_path / "file.txt" + path.write_text("content", encoding="utf-8") + return path + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("recursive", "pattern", "expected"), + [ + (False, None, ["a.txt", "z.txt"]), + (True, None, ["a.txt", "nested/b.txt", "z.txt"]), + (False, "*.txt", ["a.txt", "z.txt"]), + (True, "*.txt", ["a.txt", "nested/b.txt", "z.txt"]), + ], +) +async def test_directory_resource_returns_sorted_posix_file_paths( + directory_with_files: Path, recursive: bool, pattern: str | None, expected: list[str] +) -> None: + """SDK-defined listings contain sorted relative file paths for each glob mode.""" + resource = DirectoryResource( + uri="test://directory", name="directory", path=directory_with_files, recursive=recursive, pattern=pattern + ) + + assert json.loads(await resource.read()) == {"files": expected} + + +@pytest.mark.anyio +async def test_directory_resource_rejects_missing_directory(tmp_path: Path) -> None: + """SDK-defined directory reads report a missing path.""" + resource = DirectoryResource(uri="test://directory", name="directory", path=tmp_path / "missing") + + with pytest.raises(FileNotFoundError): + await resource.read() + + +@pytest.mark.anyio +async def test_directory_resource_rejects_regular_file(regular_file: Path) -> None: + """SDK-defined directory reads reject a regular file path.""" + resource = DirectoryResource(uri="test://directory", name="directory", path=regular_file) + + with pytest.raises(NotADirectoryError): + await resource.read() + + +def test_directory_resource_requires_absolute_path() -> None: + """SDK-defined resource validation rejects relative directory paths.""" + with pytest.raises(ValidationError): + DirectoryResource(uri="test://directory", name="directory", path=Path("relative"))