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 <strawgate@users.noreply.github.com>

* Clean-up DirectoryResource

* Update src/fastmcp/resources/types.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: William Easton <strawgate@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
William Easton 2025-10-24 18:25:15 -05:00 committed by GitHub
commit 380835593c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -5,11 +5,11 @@ from __future__ import annotations
import json
from pathlib import Path
import anyio
import anyio.to_thread
import httpx
import pydantic.json
from anyio import Path as AsyncPath
from pydantic import Field, ValidationInfo
from typing_extensions import override
from fastmcp.exceptions import ResourceError
from fastmcp.resources.resource import Resource
@ -54,6 +54,10 @@ class FileResource(Resource):
description="MIME type of the resource content",
)
@property
def _async_path(self) -> AsyncPath:
return AsyncPath(self.path)
@pydantic.field_validator("path")
@classmethod
def validate_absolute_path(cls, path: Path) -> Path:
@ -71,12 +75,13 @@ class FileResource(Resource):
mime_type = info.data.get("mime_type", "text/plain")
return not mime_type.startswith("text/")
@override
async def read(self) -> str | bytes:
"""Read the file content."""
try:
if self.is_binary:
return await anyio.to_thread.run_sync(self.path.read_bytes)
return await anyio.to_thread.run_sync(self.path.read_text)
return await self._async_path.read_bytes()
return await self._async_path.read_text()
except Exception as e:
raise ResourceError(f"Error reading file {self.path}") from e
@ -89,11 +94,12 @@ class HttpResource(Resource):
default="application/json", description="MIME type of the resource content"
)
@override
async def read(self) -> str | bytes:
"""Read the HTTP content."""
async with httpx.AsyncClient() as client:
response = await client.get(self.url)
response.raise_for_status()
_ = response.raise_for_status()
return response.text
@ -111,6 +117,10 @@ class DirectoryResource(Resource):
default="application/json", description="MIME type of the resource content"
)
@property
def _async_path(self) -> AsyncPath:
return AsyncPath(self.path)
@pydantic.field_validator("path")
@classmethod
def validate_absolute_path(cls, path: Path) -> Path:
@ -119,33 +129,29 @@ class DirectoryResource(Resource):
raise ValueError("Path must be absolute")
return path
def list_files(self) -> list[Path]:
async def list_files(self) -> list[Path]:
"""List files in the directory."""
if not self.path.exists():
if not await self._async_path.exists():
raise FileNotFoundError(f"Directory not found: {self.path}")
if not self.path.is_dir():
if not await self._async_path.is_dir():
raise NotADirectoryError(f"Not a directory: {self.path}")
try:
if self.pattern:
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("*"))
)
except Exception as e:
raise ResourceError(f"Error listing directory {self.path}: {e}")
pattern = self.pattern or "*"
glob_fn = self._async_path.rglob if self.recursive else self._async_path.glob
try:
return [Path(p) async for p in glob_fn(pattern) if await p.is_file()]
except Exception as e:
raise ResourceError(f"Error listing directory {self.path}") from e
@override
async def read(self) -> str: # Always returns JSON string
"""Read the directory listing."""
try:
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()]
files: list[Path] = await self.list_files()
file_list = [str(f.relative_to(self.path)) for f in files]
return json.dumps({"files": file_list}, indent=2)
except Exception:
raise ResourceError(f"Error reading directory {self.path}")
except Exception as e:
raise ResourceError(f"Error reading directory {self.path}") from e