Skip to content

Commit 11934c9

Browse files
authored
Replace FileResource.is_binary with an encoding field (#3171)
1 parent 814072c commit 11934c9

4 files changed

Lines changed: 240 additions & 98 deletions

File tree

docs/migration.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,37 @@ Reading a missing resource now returns JSON-RPC error code `-32602` (invalid par
842842

843843
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`).
844844

845+
### `Resource` classes reject unknown keyword arguments
846+
847+
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")`.
848+
849+
### `FileResource.is_binary` replaced by `encoding`
850+
851+
`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).
852+
853+
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.
854+
855+
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.
856+
857+
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.
858+
859+
**Before (v1):**
860+
861+
```python
862+
FileResource(uri="file:///logo.png", path=logo, mime_type="image/png", is_binary=True)
863+
FileResource(uri="file:///notes.txt", path=notes) # text, decoded with the locale encoding
864+
```
865+
866+
**After (v2):**
867+
868+
```python
869+
FileResource(uri="file:///logo.png", path=logo, mime_type="image/png") # bytes, from mime_type
870+
FileResource(uri="file:///notes.txt", path=notes) # text, decoded as UTF-8 (BOM tolerated)
871+
FileResource(uri="file:///data.json", path=data, mime_type="application/json") # now text, not a blob
872+
```
873+
874+
Pass `encoding=None` to force a blob, or `encoding="latin-1"` (etc.) to decode a text file that isn't UTF-8.
875+
845876
### Resource templates: matching behavior changes
846877

847878
Resource template matching has been rewritten with [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) support.

src/mcp/server/mcpserver/resources/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
class Resource(BaseModel, abc.ABC):
1717
"""Base class for all resources."""
1818

19-
model_config = ConfigDict(validate_default=True)
19+
model_config = ConfigDict(validate_default=True, extra="forbid")
2020

2121
uri: str = Field(default=..., description="URI of the resource")
2222
name: str | None = Field(description="Name of the resource", default=None)

src/mcp/server/mcpserver/resources/types.py

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import json
66
from collections.abc import Callable
7+
from functools import partial
78
from pathlib import Path
89
from typing import Any
910

@@ -13,12 +14,34 @@
1314
import pydantic
1415
import pydantic_core
1516
from mcp_types import Annotations, Icon, InputRequiredResult
16-
from pydantic import Field, ValidationInfo, validate_call
17+
from pydantic import Field, validate_call
1718

1819
from mcp.server.mcpserver.resources.base import Resource
1920
from mcp.shared._callable_inspection import is_async_callable
2021
from mcp.shared.exceptions import MCPError
2122

23+
# `application/*` types that are textual but predate the `+json`/`+xml`
24+
# structured-syntax suffixes, so the suffix rule below can't catch them.
25+
_TEXTUAL_APPLICATION_TYPES = frozenset({"application/json", "application/xml"})
26+
27+
28+
def _default_file_encoding(mime_type: str) -> str | None:
29+
"""The encoding a file of this mime type is decoded with by default.
30+
31+
A declared `charset=` parameter wins. Otherwise textual types (`text/*`, JSON,
32+
XML) are `utf-8-sig` — UTF-8 that also tolerates a byte-order mark — and
33+
everything else is bytes (None).
34+
"""
35+
essence, *params = (part.strip() for part in mime_type.split(";"))
36+
for param in params:
37+
name, _, value = param.partition("=")
38+
if name.strip().lower() == "charset" and value:
39+
return value.strip().strip('"')
40+
essence = essence.lower()
41+
if essence.startswith("text/") or essence.endswith(("+json", "+xml")) or essence in _TEXTUAL_APPLICATION_TYPES:
42+
return "utf-8-sig"
43+
return None
44+
2245

2346
class TextResource(Resource):
2447
"""A resource that reads from a string."""
@@ -122,17 +145,17 @@ def from_function(
122145
class FileResource(Resource):
123146
"""A resource that reads from a file.
124147
125-
Set is_binary=True to read the file as binary data instead of text.
148+
The file is decoded with `encoding` and served as text, or read as bytes and
149+
served as a base64 blob when `encoding` is None. When `encoding` is omitted it
150+
defaults to the `charset` declared in `mime_type`, else `"utf-8-sig"` for
151+
textual mime types (`text/*`, JSON, XML) and None for everything else; pass
152+
it explicitly to override either way.
126153
"""
127154

128155
path: Path = Field(description="Path to the file")
129-
is_binary: bool = Field(
130-
default=False,
131-
description="Whether to read the file as binary data",
132-
)
133-
mime_type: str = Field(
134-
default="text/plain",
135-
description="MIME type of the resource content",
156+
encoding: str | None = Field(
157+
default_factory=lambda data: _default_file_encoding(data["mime_type"]),
158+
description="Text encoding used to decode the file, or None to serve its bytes as a blob",
136159
)
137160

138161
@pydantic.field_validator("path")
@@ -143,21 +166,27 @@ def validate_absolute_path(cls, path: Path) -> Path:
143166
raise ValueError("Path must be absolute")
144167
return path
145168

146-
@pydantic.field_validator("is_binary")
169+
@pydantic.field_validator("encoding")
147170
@classmethod
148-
def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool:
149-
"""Set is_binary based on mime_type if not explicitly set."""
150-
if is_binary:
151-
return True
152-
mime_type = info.data.get("mime_type", "text/plain")
153-
return not mime_type.startswith("text/")
171+
def validate_text_encoding(cls, encoding: str | None) -> str | None:
172+
"""Ensure the encoding names a usable text codec, so a mistake fails at construction not at read."""
173+
if encoding is not None:
174+
# Decoding a probe byte rejects both unknown names and codecs that
175+
# aren't text encodings (base64_codec, rot13, ...) via LookupError.
176+
try:
177+
b"x".decode(encoding)
178+
except LookupError as e:
179+
raise ValueError(str(e)) from e
180+
except UnicodeError:
181+
pass # a real text encoding; the probe byte just doesn't decode in it
182+
return encoding
154183

155184
async def read(self) -> str | bytes:
156185
"""Read the file content."""
157186
try:
158-
if self.is_binary:
187+
if self.encoding is None:
159188
return await anyio.to_thread.run_sync(self.path.read_bytes)
160-
return await anyio.to_thread.run_sync(self.path.read_text)
189+
return await anyio.to_thread.run_sync(partial(self.path.read_text, encoding=self.encoding))
161190
except Exception as e:
162191
raise ValueError(f"Error reading file {self.path}: {e}")
163192

0 commit comments

Comments
 (0)