Replace FileResource.is_binary with an encoding field (#3171)

This commit is contained in:
Max 2026-07-26 00:58:06 +01:00 • committed by GitHub
commit 11934c90ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 240 additions and 98 deletions

View file

@ -842,6 +842,37 @@ Reading a missing resource now returns JSON-RPC error code `-32602` (invalid par
The underlying lookups now raise typed exceptions instead of `ValueError`. `ResourceManager.get_resource()` raises `ResourceNotFoundError` when no resource or template matches the URI, and `ResourceTemplate.create_resource()` raises `ResourceError` when the template function fails. Neither subclasses `ValueError`, so callers catching `ValueError` should switch to `ResourceNotFoundError` / `ResourceError` (both importable from `mcp.server.mcpserver.exceptions`; `ResourceNotFoundError` subclasses `ResourceError`). The underlying lookups now raise typed exceptions instead of `ValueError`. `ResourceManager.get_resource()` raises `ResourceNotFoundError` when no resource or template matches the URI, and `ResourceTemplate.create_resource()` raises `ResourceError` when the template function fails. Neither subclasses `ValueError`, so callers catching `ValueError` should switch to `ResourceNotFoundError` / `ResourceError` (both importable from `mcp.server.mcpserver.exceptions`; `ResourceNotFoundError` subclasses `ResourceError`).
### `Resource` classes reject unknown keyword arguments
The `Resource` base class now sets `extra="forbid"`, so every resource class — `TextResource`, `BinaryResource`, `FunctionResource`, `FileResource`, `HttpResource`, `DirectoryResource`, and your own subclasses — raises `ValidationError` on an unrecognised keyword argument instead of silently dropping it. Previously a typo'd or since-removed parameter (such as `FileResource(is_binary=...)`, below) was accepted and ignored. Remove any stray keyword arguments; if a subclass needs to accept arbitrary extras, set its own `model_config = ConfigDict(extra="allow")`.
### `FileResource.is_binary` replaced by `encoding`
`FileResource` used to take `is_binary: bool` and guess its default from `mime_type` (`text/*` → text, anything else → bytes). Two problems fell out of that: `is_binary=False` could not actually be set — `False` doubled as the "not given" sentinel, so `mime_type="application/json"` always came back as a base64 blob — and text reads used `Path.read_text()` with no encoding, i.e. the platform locale (cp1252 on Windows).
The field is now `encoding: str | None`. A string means "decode with this encoding and serve as text"; `None` means "read bytes and serve as a blob". When omitted it defaults to the `charset` declared in `mime_type` if there is one, otherwise `"utf-8-sig"` for textual mime types (`text/*`, `application/json`, `application/xml`, and any `+json`/`+xml` suffix) and `None` for everything else, so JSON and XML files are now served as text without any configuration. `utf-8-sig` is plain UTF-8 that also drops a byte-order mark if the file has one; a declared `charset=` is used as-is.
Passing the removed `is_binary=` argument now raises a `ValidationError` at construction (see the section above) rather than being silently ignored. A misspelled `encoding` also fails at construction rather than on the first read.
Two edge cases to check. A non-UTF-8 file with a *newly* textual mime type (say a UTF-16 `application/xml`) previously shipped byte-exact as a blob and now fails to decode. And an existing `text/*` file that was only readable through your platform's locale encoding (v1 decoded these with the locale, not UTF-8) now fails too. In both cases set `encoding` to the file's real encoding, or `encoding=None` to serve the bytes as a blob.
**Before (v1):**
```python
FileResource(uri="file:///logo.png", path=logo, mime_type="image/png", is_binary=True)
FileResource(uri="file:///notes.txt", path=notes) # text, decoded with the locale encoding
```
**After (v2):**
```python
FileResource(uri="file:///logo.png", path=logo, mime_type="image/png") # bytes, from mime_type
FileResource(uri="file:///notes.txt", path=notes) # text, decoded as UTF-8 (BOM tolerated)
FileResource(uri="file:///data.json", path=data, mime_type="application/json") # now text, not a blob
```
Pass `encoding=None` to force a blob, or `encoding="latin-1"` (etc.) to decode a text file that isn't UTF-8.
### Resource templates: matching behavior changes ### Resource templates: matching behavior changes
Resource template matching has been rewritten with [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) support. Resource template matching has been rewritten with [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) support.

View file

@ -16,7 +16,7 @@ from pydantic import (
class Resource(BaseModel, abc.ABC): class Resource(BaseModel, abc.ABC):
"""Base class for all resources.""" """Base class for all resources."""
model_config = ConfigDict(validate_default=True) model_config = ConfigDict(validate_default=True, extra="forbid")
uri: str = Field(default=..., description="URI of the resource") uri: str = Field(default=..., description="URI of the resource")
name: str | None = Field(description="Name of the resource", default=None) name: str | None = Field(description="Name of the resource", default=None)

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import json import json
from collections.abc import Callable from collections.abc import Callable
from functools import partial
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -13,12 +14,34 @@ import httpx2
import pydantic import pydantic
import pydantic_core import pydantic_core
from mcp_types import Annotations, Icon, InputRequiredResult from mcp_types import Annotations, Icon, InputRequiredResult
from pydantic import Field, ValidationInfo, validate_call from pydantic import Field, validate_call
from mcp.server.mcpserver.resources.base import Resource from mcp.server.mcpserver.resources.base import Resource
from mcp.shared._callable_inspection import is_async_callable from mcp.shared._callable_inspection import is_async_callable
from mcp.shared.exceptions import MCPError from mcp.shared.exceptions import MCPError
# `application/*` types that are textual but predate the `+json`/`+xml`
# structured-syntax suffixes, so the suffix rule below can't catch them.
_TEXTUAL_APPLICATION_TYPES = frozenset({"application/json", "application/xml"})
def _default_file_encoding(mime_type: str) -> str | None:
"""The encoding a file of this mime type is decoded with by default.
A declared `charset=` parameter wins. Otherwise textual types (`text/*`, JSON,
XML) are `utf-8-sig` — UTF-8 that also tolerates a byte-order mark — and
everything else is bytes (None).
"""
essence, *params = (part.strip() for part in mime_type.split(";"))
for param in params:
name, _, value = param.partition("=")
if name.strip().lower() == "charset" and value:
return value.strip().strip('"')
essence = essence.lower()
if essence.startswith("text/") or essence.endswith(("+json", "+xml")) or essence in _TEXTUAL_APPLICATION_TYPES:
return "utf-8-sig"
return None
class TextResource(Resource): class TextResource(Resource):
"""A resource that reads from a string.""" """A resource that reads from a string."""
@ -122,17 +145,17 @@ class FunctionResource(Resource):
class FileResource(Resource): class FileResource(Resource):
"""A resource that reads from a file. """A resource that reads from a file.
Set is_binary=True to read the file as binary data instead of text. The file is decoded with `encoding` and served as text, or read as bytes and
served as a base64 blob when `encoding` is None. When `encoding` is omitted it
defaults to the `charset` declared in `mime_type`, else `"utf-8-sig"` for
textual mime types (`text/*`, JSON, XML) and None for everything else; pass
it explicitly to override either way.
""" """
path: Path = Field(description="Path to the file") path: Path = Field(description="Path to the file")
is_binary: bool = Field( encoding: str | None = Field(
default=False, default_factory=lambda data: _default_file_encoding(data["mime_type"]),
description="Whether to read the file as binary data", description="Text encoding used to decode the file, or None to serve its bytes as a blob",
)
mime_type: str = Field(
default="text/plain",
description="MIME type of the resource content",
) )
@pydantic.field_validator("path") @pydantic.field_validator("path")
@ -143,21 +166,27 @@ class FileResource(Resource):
raise ValueError("Path must be absolute") raise ValueError("Path must be absolute")
return path return path
@pydantic.field_validator("is_binary") @pydantic.field_validator("encoding")
@classmethod @classmethod
def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool: def validate_text_encoding(cls, encoding: str | None) -> str | None:
"""Set is_binary based on mime_type if not explicitly set.""" """Ensure the encoding names a usable text codec, so a mistake fails at construction not at read."""
if is_binary: if encoding is not None:
return True # Decoding a probe byte rejects both unknown names and codecs that
mime_type = info.data.get("mime_type", "text/plain") # aren't text encodings (base64_codec, rot13, ...) via LookupError.
return not mime_type.startswith("text/") try:
b"x".decode(encoding)
except LookupError as e:
raise ValueError(str(e)) from e
except UnicodeError:
pass # a real text encoding; the probe byte just doesn't decode in it
return encoding
async def read(self) -> str | bytes: async def read(self) -> str | bytes:
"""Read the file content.""" """Read the file content."""
try: try:
if self.is_binary: if self.encoding is None:
return await anyio.to_thread.run_sync(self.path.read_bytes) return await anyio.to_thread.run_sync(self.path.read_bytes)
return await anyio.to_thread.run_sync(self.path.read_text) return await anyio.to_thread.run_sync(partial(self.path.read_text, encoding=self.encoding))
except Exception as e: except Exception as e:
raise ValueError(f"Error reading file {self.path}: {e}") raise ValueError(f"Error reading file {self.path}: {e}")

View file

@ -1,8 +1,10 @@
import codecs
import os import os
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
import pytest import pytest
from pydantic import ValidationError
from mcp.server.mcpserver.resources import FileResource from mcp.server.mcpserver.resources import FileResource
@ -24,11 +26,7 @@ def temp_file():
pass # File was already deleted by the test pass # File was already deleted by the test
class TestFileResource: def test_file_resource_creation(temp_file: Path):
"""Test FileResource functionality."""
def test_file_resource_creation(self, temp_file: Path):
"""Test creating a FileResource."""
resource = FileResource( resource = FileResource(
uri=temp_file.as_uri(), uri=temp_file.as_uri(),
name="test", name="test",
@ -38,12 +36,12 @@ class TestFileResource:
assert str(resource.uri) == temp_file.as_uri() assert str(resource.uri) == temp_file.as_uri()
assert resource.name == "test" assert resource.name == "test"
assert resource.description == "test file" assert resource.description == "test file"
assert resource.mime_type == "text/plain" # default assert resource.mime_type == "text/plain"
assert resource.path == temp_file assert resource.path == temp_file
assert resource.is_binary is False # default assert resource.encoding == "utf-8-sig"
def test_file_resource_str_path_conversion(self, temp_file: Path):
"""Test FileResource handles string paths.""" def test_file_resource_str_path_conversion(temp_file: Path):
resource = FileResource( resource = FileResource(
uri=f"file://{temp_file}", uri=f"file://{temp_file}",
name="test", name="test",
@ -52,9 +50,9 @@ class TestFileResource:
assert isinstance(resource.path, Path) assert isinstance(resource.path, Path)
assert resource.path.is_absolute() assert resource.path.is_absolute()
@pytest.mark.anyio @pytest.mark.anyio
async def test_read_text_file(self, temp_file: Path): async def test_read_text_file(temp_file: Path):
"""Test reading a text file."""
resource = FileResource( resource = FileResource(
uri=f"file://{temp_file}", uri=f"file://{temp_file}",
name="test", name="test",
@ -64,21 +62,106 @@ class TestFileResource:
assert content == "test content" assert content == "test content"
assert resource.mime_type == "text/plain" assert resource.mime_type == "text/plain"
@pytest.mark.anyio @pytest.mark.anyio
async def test_read_binary_file(self, temp_file: Path): async def test_encoding_none_reads_bytes(temp_file: Path):
"""Test reading a file as binary."""
resource = FileResource( resource = FileResource(
uri=f"file://{temp_file}", uri=f"file://{temp_file}",
name="test", name="test",
path=temp_file, path=temp_file,
is_binary=True, encoding=None,
) )
content = await resource.read() content = await resource.read()
assert isinstance(content, bytes) assert isinstance(content, bytes)
assert content == b"test content" assert content == b"test content"
def test_relative_path_error(self):
"""Test error on relative path.""" @pytest.mark.parametrize(
"mime_type",
[
"text/plain",
"text/html",
"application/json",
"application/xml",
"application/vnd.api+json",
"image/svg+xml",
],
)
def test_textual_mime_types_default_to_utf8_sig(temp_file: Path, mime_type: str):
resource = FileResource(uri=temp_file.as_uri(), path=temp_file, mime_type=mime_type)
assert resource.encoding == "utf-8-sig"
@pytest.mark.parametrize("mime_type", ["image/png", "application/octet-stream", "application/pdf"])
def test_binary_mime_types_default_to_no_encoding(temp_file: Path, mime_type: str):
resource = FileResource(uri=temp_file.as_uri(), path=temp_file, mime_type=mime_type)
assert resource.encoding is None
@pytest.mark.parametrize(
"mime_type",
["text/plain; charset=iso-8859-1", 'text/plain; format=flowed; charset="iso-8859-1"'],
)
def test_declared_charset_becomes_default_encoding(temp_file: Path, mime_type: str):
resource = FileResource(uri=temp_file.as_uri(), path=temp_file, mime_type=mime_type)
assert resource.encoding == "iso-8859-1"
def test_removed_is_binary_kwarg_is_rejected(temp_file: Path):
"""The v1 `is_binary` parameter fails loudly at construction rather than being ignored."""
with pytest.raises(ValidationError, match="is_binary"):
FileResource.model_validate({"uri": temp_file.as_uri(), "path": temp_file, "is_binary": True})
def test_unknown_encoding_is_rejected(temp_file: Path):
"""A codec typo fails at construction rather than on the first read."""
with pytest.raises(ValidationError, match="unknown encoding: not-a-codec"):
FileResource(uri=temp_file.as_uri(), path=temp_file, encoding="not-a-codec")
def test_unknown_declared_charset_is_rejected(temp_file: Path):
with pytest.raises(ValidationError, match="unknown encoding"):
FileResource(uri=temp_file.as_uri(), path=temp_file, mime_type="text/plain; charset=not-a-codec")
def test_multibyte_encoding_is_accepted(temp_file: Path):
"""UTF-16 can't decode a lone probe byte but is still a valid text encoding."""
resource = FileResource(uri=temp_file.as_uri(), path=temp_file, encoding="utf-16")
assert resource.encoding == "utf-16"
def test_non_text_codec_is_rejected(temp_file: Path):
"""A registered codec that isn't a text encoding (bytes-to-bytes) is not a usable encoding."""
with pytest.raises(ValidationError, match="not a text encoding"):
FileResource(uri=temp_file.as_uri(), path=temp_file, encoding="base64_codec")
@pytest.mark.anyio
async def test_json_file_is_served_as_text_by_default(temp_file: Path):
"""The mime type that motivated the encoding field: JSON must not become a base64 blob."""
temp_file.write_text('{"a": 1}', encoding="utf-8")
resource = FileResource(uri=temp_file.as_uri(), path=temp_file, mime_type="application/json")
assert await resource.read() == '{"a": 1}'
@pytest.mark.anyio
async def test_utf8_bom_is_stripped_by_default(temp_file: Path):
"""The default utf-8-sig decoding drops a byte-order mark that would otherwise break JSON parsers."""
temp_file.write_bytes(codecs.BOM_UTF8 + b'{"a": 1}')
resource = FileResource(uri=temp_file.as_uri(), path=temp_file, mime_type="application/json")
assert await resource.read() == '{"a": 1}'
@pytest.mark.anyio
async def test_explicit_encoding_overrides_default(temp_file: Path):
"""An explicit encoding wins over the mime-type default and is what decodes the file."""
temp_file.write_bytes("naïve".encode("latin-1"))
resource = FileResource(uri=temp_file.as_uri(), path=temp_file, mime_type="image/png", encoding="latin-1")
assert resource.encoding == "latin-1"
assert await resource.read() == "naïve"
def test_relative_path_error():
with pytest.raises(ValueError, match="Path must be absolute"): with pytest.raises(ValueError, match="Path must be absolute"):
FileResource( FileResource(
uri="file:///test.txt", uri="file:///test.txt",
@ -86,10 +169,9 @@ class TestFileResource:
path=Path("test.txt"), path=Path("test.txt"),
) )
@pytest.mark.anyio @pytest.mark.anyio
async def test_missing_file_error(self, temp_file: Path): async def test_missing_file_error(temp_file: Path):
"""Test error when file doesn't exist."""
# Create path to non-existent file
missing = temp_file.parent / "missing.txt" missing = temp_file.parent / "missing.txt"
resource = FileResource( resource = FileResource(
uri="file:///missing.txt", uri="file:///missing.txt",
@ -99,10 +181,10 @@ class TestFileResource:
with pytest.raises(ValueError, match="Error reading file"): with pytest.raises(ValueError, match="Error reading file"):
await resource.read() await resource.read()
@pytest.mark.skipif(os.name == "nt", reason="File permissions behave differently on Windows") @pytest.mark.skipif(os.name == "nt", reason="File permissions behave differently on Windows")
@pytest.mark.anyio @pytest.mark.anyio
async def test_permission_error(self, temp_file: Path): # pragma: lax no cover async def test_permission_error(temp_file: Path): # pragma: lax no cover
"""Test reading a file without permissions."""
temp_file.chmod(0o000) # Remove all permissions temp_file.chmod(0o000) # Remove all permissions
try: try:
resource = FileResource( resource = FileResource(