Skip to content

Commit 3808355

Browse files
strawgategithub-actions[bot]Copilot
authored
Async FileResource and DirectoryResource (#2241)
* Improve DirectoryResource exception logging and async implementation - Add exception logging before raising ResourceError in read() method - Convert list_files() to async-native using anyio.Path - Update read() to await async list_files() and use async is_file() check - Remove synchronous thread wrapper in favor of native async I/O Co-authored-by: William Easton <[email protected]> * Clean-up DirectoryResource * Update src/fastmcp/resources/types.py Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: William Easton <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 1e5776f commit 3808355

File tree

1 file changed

+30
-24
lines changed

1 file changed

+30
-24
lines changed

src/fastmcp/resources/types.py

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
import json
66
from pathlib import Path
77

8-
import anyio
9-
import anyio.to_thread
108
import httpx
119
import pydantic.json
10+
from anyio import Path as AsyncPath
1211
from pydantic import Field, ValidationInfo
12+
from typing_extensions import override
1313

1414
from fastmcp.exceptions import ResourceError
1515
from fastmcp.resources.resource import Resource
@@ -54,6 +54,10 @@ class FileResource(Resource):
5454
description="MIME type of the resource content",
5555
)
5656

57+
@property
58+
def _async_path(self) -> AsyncPath:
59+
return AsyncPath(self.path)
60+
5761
@pydantic.field_validator("path")
5862
@classmethod
5963
def validate_absolute_path(cls, path: Path) -> Path:
@@ -71,12 +75,13 @@ def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> boo
7175
mime_type = info.data.get("mime_type", "text/plain")
7276
return not mime_type.startswith("text/")
7377

78+
@override
7479
async def read(self) -> str | bytes:
7580
"""Read the file content."""
7681
try:
7782
if self.is_binary:
78-
return await anyio.to_thread.run_sync(self.path.read_bytes)
79-
return await anyio.to_thread.run_sync(self.path.read_text)
83+
return await self._async_path.read_bytes()
84+
return await self._async_path.read_text()
8085
except Exception as e:
8186
raise ResourceError(f"Error reading file {self.path}") from e
8287

@@ -89,11 +94,12 @@ class HttpResource(Resource):
8994
default="application/json", description="MIME type of the resource content"
9095
)
9196

97+
@override
9298
async def read(self) -> str | bytes:
9399
"""Read the HTTP content."""
94100
async with httpx.AsyncClient() as client:
95101
response = await client.get(self.url)
96-
response.raise_for_status()
102+
_ = response.raise_for_status()
97103
return response.text
98104

99105

@@ -111,6 +117,10 @@ class DirectoryResource(Resource):
111117
default="application/json", description="MIME type of the resource content"
112118
)
113119

120+
@property
121+
def _async_path(self) -> AsyncPath:
122+
return AsyncPath(self.path)
123+
114124
@pydantic.field_validator("path")
115125
@classmethod
116126
def validate_absolute_path(cls, path: Path) -> Path:
@@ -119,33 +129,29 @@ def validate_absolute_path(cls, path: Path) -> Path:
119129
raise ValueError("Path must be absolute")
120130
return path
121131

122-
def list_files(self) -> list[Path]:
132+
async def list_files(self) -> list[Path]:
123133
"""List files in the directory."""
124-
if not self.path.exists():
134+
if not await self._async_path.exists():
125135
raise FileNotFoundError(f"Directory not found: {self.path}")
126-
if not self.path.is_dir():
136+
if not await self._async_path.is_dir():
127137
raise NotADirectoryError(f"Not a directory: {self.path}")
128138

139+
pattern = self.pattern or "*"
140+
141+
glob_fn = self._async_path.rglob if self.recursive else self._async_path.glob
129142
try:
130-
if self.pattern:
131-
return (
132-
list(self.path.glob(self.pattern))
133-
if not self.recursive
134-
else list(self.path.rglob(self.pattern))
135-
)
136-
return (
137-
list(self.path.glob("*"))
138-
if not self.recursive
139-
else list(self.path.rglob("*"))
140-
)
143+
return [Path(p) async for p in glob_fn(pattern) if await p.is_file()]
141144
except Exception as e:
142-
raise ResourceError(f"Error listing directory {self.path}: {e}")
145+
raise ResourceError(f"Error listing directory {self.path}") from e
143146

147+
@override
144148
async def read(self) -> str: # Always returns JSON string
145149
"""Read the directory listing."""
146150
try:
147-
files = await anyio.to_thread.run_sync(self.list_files)
148-
file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
151+
files: list[Path] = await self.list_files()
152+
153+
file_list = [str(f.relative_to(self.path)) for f in files]
154+
149155
return json.dumps({"files": file_list}, indent=2)
150-
except Exception:
151-
raise ResourceError(f"Error reading directory {self.path}")
156+
except Exception as e:
157+
raise ResourceError(f"Error reading directory {self.path}") from e

0 commit comments

Comments
 (0)