diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx index 0a8a772aa..77843f252 100644 --- a/docs/servers/resources.mdx +++ b/docs/servers/resources.mdx @@ -349,7 +349,7 @@ if data_dir_path.is_dir(): - `TextResource`: For simple string content. - `BinaryResource`: For raw `bytes` content. -- `FileResource`: Reads content from a local file path. Handles text/binary modes and lazy reading. +- `FileResource`: Reads content from a local file path. Handles text/binary modes, encoding, and lazy reading. - `HttpResource`: Fetches content from an HTTP(S) URL (requires `httpx`). - `DirectoryResource`: Lists files in a local directory (returns JSON). - (`FunctionResource`: Internal class used by `@mcp.resource`). diff --git a/src/fastmcp/resources/types.py b/src/fastmcp/resources/types.py index 72e2bdd9e..ed514c5ce 100644 --- a/src/fastmcp/resources/types.py +++ b/src/fastmcp/resources/types.py @@ -65,6 +65,14 @@ class FileResource(Resource): default="text/plain", description="MIME type of the resource content", ) + encoding: str | None = Field( + default="utf-8", + description=( + "Encoding to use when reading text files. " + "Defaults to 'utf-8' for cross-platform compatibility. " + "Set to None to use the system default encoding." + ), + ) @property def _async_path(self) -> AsyncPath: @@ -94,7 +102,7 @@ class FileResource(Resource): if self.is_binary: content: str | bytes = await self._async_path.read_bytes() else: - content = await self._async_path.read_text() + content = await self._async_path.read_text(encoding=self.encoding) return ResourceResult( contents=[ResourceContent(content=content, mime_type=self.mime_type)] ) diff --git a/tests/resources/test_file_resources.py b/tests/resources/test_file_resources.py index e67e06802..cf8fb0c14 100644 --- a/tests/resources/test_file_resources.py +++ b/tests/resources/test_file_resources.py @@ -119,3 +119,63 @@ class TestFileResource: await resource.read() finally: temp_file.chmod(0o644) # Restore permissions + + async def test_read_utf8_with_encoding(self, tmp_path: Path): + """FileResource should read UTF-8 files correctly when encoding is specified.""" + content = ( + "Smart quotes: \u201cleft\u201d and apostrophe\u2019s em-dash\u2014here" + ) + file = tmp_path / "utf8_test.md" + file.write_text(content, encoding="utf-8") + + resource = FileResource( + uri=FileUrl("file:///test/utf8"), + path=file, + mime_type="text/markdown", + encoding="utf-8", + ) + result = await resource.read() + assert result.contents[0].content == content + + async def test_default_encoding_is_utf8(self, tmp_path: Path): + """FileResource defaults to UTF-8, reading non-ASCII without explicit encoding.""" + content = "Smart quotes: \u201cleft\u201d and em-dash\u2014here" + file = tmp_path / "default_utf8_test.txt" + file.write_text(content, encoding="utf-8") + + resource = FileResource( + uri=FileUrl("file:///test/default"), + path=file, + ) + assert resource.encoding == "utf-8" + result = await resource.read() + assert result.contents[0].content == content + + async def test_encoding_ignored_for_binary(self, tmp_path: Path): + """Encoding field should be ignored when is_binary=True.""" + data = b"\x00\x01\x02\xff" + file = tmp_path / "binary_test.bin" + file.write_bytes(data) + + resource = FileResource( + uri=FileUrl("file:///test/binary"), + path=file, + mime_type="application/octet-stream", + encoding="utf-8", + ) + result = await resource.read() + assert result.contents[0].content == data + + async def test_read_latin1_with_encoding(self, tmp_path: Path): + """FileResource should read non-UTF-8 files when correct encoding is specified.""" + content = "na\u00efve" + file = tmp_path / "latin1_test.txt" + file.write_text(content, encoding="latin-1") + + resource = FileResource( + uri=FileUrl("file:///test/latin1"), + path=file, + encoding="latin-1", + ) + result = await resource.read() + assert result.contents[0].content == content