Skip to content

Commit 769fd84

Browse files
committed
Shrink the textual mime set and default text to utf-8-sig
The set of application/* types treated as text is now just json and xml, the two ubiquitous un-suffixed textual types the structural rules (text/*, +json/+xml) can't reach. application/javascript and application/ecmascript are gone: RFC 9239 made text/javascript canonical, which the text/* rule already covers. The inferred text encoding is now utf-8-sig, so a byte-order mark is dropped rather than surfacing as a leading U+FEFF that JSON parsers reject. A declared charset= is still used as-is. The encoding is validated with codecs.lookup at construction, so a typo fails at registration instead of on the first read. The extra="forbid" change gets its own migration section, since it applies to every Resource class, not just FileResource.
1 parent a7a9ab5 commit 769fd84

3 files changed

Lines changed: 55 additions & 22 deletions

File tree

docs/migration.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -826,13 +826,19 @@ Reading a missing resource now returns JSON-RPC error code `-32602` (invalid par
826826

827827
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`).
828828

829+
### `Resource` classes reject unknown keyword arguments
830+
831+
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")`.
832+
829833
### `FileResource.is_binary` replaced by `encoding`
830834

831835
`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).
832836

833-
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"` for textual mime types (`text/*`, `application/json`, `application/xml`, `application/javascript`, and any `+json`/`+xml` suffix) and `None` for everything else, so JSON and XML files are now served as text without any configuration.
837+
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.
838+
839+
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.
834840

835-
Passing the removed `is_binary=` argument raises a `ValidationError` at construction rather than being silently ignored — all `Resource` classes now reject unknown keyword arguments.
841+
One case flips the other way: a non-UTF-8 file with a textual mime type (say UTF-16 XML) previously shipped byte-exact as a blob and now fails to decode. Set `encoding` to the file's real encoding, or `encoding=None` to keep serving it as a blob.
836842

837843
**Before (v1):**
838844

@@ -845,7 +851,7 @@ FileResource(uri="file:///notes.txt", path=notes) # text, decoded with the loca
845851

846852
```python
847853
FileResource(uri="file:///logo.png", path=logo, mime_type="image/png") # bytes, from mime_type
848-
FileResource(uri="file:///notes.txt", path=notes) # text, decoded as UTF-8
854+
FileResource(uri="file:///notes.txt", path=notes) # text, decoded as UTF-8 (BOM tolerated)
849855
FileResource(uri="file:///data.json", path=data, mime_type="application/json") # now text, not a blob
850856
```
851857

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

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import codecs
56
import json
67
from collections.abc import Callable
78
from functools import partial
@@ -20,21 +21,17 @@
2021
from mcp.shared._callable_inspection import is_async_callable
2122
from mcp.shared.exceptions import MCPError
2223

23-
_TEXTUAL_APPLICATION_TYPES = frozenset(
24-
{
25-
"application/json",
26-
"application/xml",
27-
"application/javascript",
28-
"application/ecmascript",
29-
}
30-
)
24+
# `application/*` types that are textual but predate the `+json`/`+xml`
25+
# structured-syntax suffixes, so the suffix rule below can't catch them.
26+
_TEXTUAL_APPLICATION_TYPES = frozenset({"application/json", "application/xml"})
3127

3228

3329
def _default_file_encoding(mime_type: str) -> str | None:
3430
"""The encoding a file of this mime type is decoded with by default.
3531
36-
A declared `charset=` parameter wins. Otherwise textual types (`text/*`,
37-
JSON, XML, JavaScript) are UTF-8 and everything else is bytes (None).
32+
A declared `charset=` parameter wins. Otherwise textual types (`text/*`, JSON,
33+
XML) are `utf-8-sig` — UTF-8 that also tolerates a byte-order mark — and
34+
everything else is bytes (None).
3835
"""
3936
essence, *params = (part.strip() for part in mime_type.split(";"))
4037
for param in params:
@@ -43,7 +40,7 @@ def _default_file_encoding(mime_type: str) -> str | None:
4340
return value.strip().strip('"')
4441
essence = essence.lower()
4542
if essence.startswith("text/") or essence.endswith(("+json", "+xml")) or essence in _TEXTUAL_APPLICATION_TYPES:
46-
return "utf-8"
43+
return "utf-8-sig"
4744
return None
4845

4946

@@ -151,9 +148,9 @@ class FileResource(Resource):
151148
152149
The file is decoded with `encoding` and served as text, or read as bytes and
153150
served as a base64 blob when `encoding` is None. When `encoding` is omitted it
154-
defaults to the `charset` declared in `mime_type`, else `"utf-8"` for textual
155-
mime types (`text/*`, JSON, XML, JavaScript) and None for everything else;
156-
pass it explicitly to override either way.
151+
defaults to the `charset` declared in `mime_type`, else `"utf-8-sig"` for
152+
textual mime types (`text/*`, JSON, XML) and None for everything else; pass
153+
it explicitly to override either way.
157154
"""
158155

159156
path: Path = Field(description="Path to the file")
@@ -170,6 +167,17 @@ def validate_absolute_path(cls, path: Path) -> Path:
170167
raise ValueError("Path must be absolute")
171168
return path
172169

170+
@pydantic.field_validator("encoding")
171+
@classmethod
172+
def validate_known_encoding(cls, encoding: str | None) -> str | None:
173+
"""Ensure the encoding names a known codec, so a typo fails at construction not at read."""
174+
if encoding is not None:
175+
try:
176+
codecs.lookup(encoding)
177+
except LookupError as e:
178+
raise ValueError(f"unknown encoding: {encoding}") from e
179+
return encoding
180+
173181
async def read(self) -> str | bytes:
174182
"""Read the file content."""
175183
try:

tests/server/mcpserver/resources/test_file_resources.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import codecs
12
import os
23
from pathlib import Path
34
from tempfile import NamedTemporaryFile
@@ -37,7 +38,7 @@ def test_file_resource_creation(temp_file: Path):
3738
assert resource.description == "test file"
3839
assert resource.mime_type == "text/plain"
3940
assert resource.path == temp_file
40-
assert resource.encoding == "utf-8"
41+
assert resource.encoding == "utf-8-sig"
4142

4243

4344
def test_file_resource_str_path_conversion(temp_file: Path):
@@ -79,17 +80,16 @@ async def test_encoding_none_reads_bytes(temp_file: Path):
7980
"mime_type",
8081
[
8182
"text/plain",
82-
"text/html; charset=utf-8",
83+
"text/html",
8384
"application/json",
8485
"application/xml",
8586
"application/vnd.api+json",
8687
"image/svg+xml",
87-
"application/javascript",
8888
],
8989
)
90-
def test_textual_mime_types_default_to_utf8(temp_file: Path, mime_type: str):
90+
def test_textual_mime_types_default_to_utf8_sig(temp_file: Path, mime_type: str):
9191
resource = FileResource(uri=temp_file.as_uri(), path=temp_file, mime_type=mime_type)
92-
assert resource.encoding == "utf-8"
92+
assert resource.encoding == "utf-8-sig"
9393

9494

9595
@pytest.mark.parametrize("mime_type", ["image/png", "application/octet-stream", "application/pdf"])
@@ -113,6 +113,17 @@ def test_removed_is_binary_kwarg_is_rejected(temp_file: Path):
113113
FileResource.model_validate({"uri": temp_file.as_uri(), "path": temp_file, "is_binary": True})
114114

115115

116+
def test_unknown_encoding_is_rejected(temp_file: Path):
117+
"""A codec typo fails at construction rather than on the first read."""
118+
with pytest.raises(ValidationError, match="unknown encoding: not-a-codec"):
119+
FileResource(uri=temp_file.as_uri(), path=temp_file, encoding="not-a-codec")
120+
121+
122+
def test_unknown_declared_charset_is_rejected(temp_file: Path):
123+
with pytest.raises(ValidationError, match="unknown encoding"):
124+
FileResource(uri=temp_file.as_uri(), path=temp_file, mime_type="text/plain; charset=not-a-codec")
125+
126+
116127
@pytest.mark.anyio
117128
async def test_json_file_is_served_as_text_by_default(temp_file: Path):
118129
"""The mime type that motivated the encoding field: JSON must not become a base64 blob."""
@@ -121,6 +132,14 @@ async def test_json_file_is_served_as_text_by_default(temp_file: Path):
121132
assert await resource.read() == '{"a": 1}'
122133

123134

135+
@pytest.mark.anyio
136+
async def test_utf8_bom_is_stripped_by_default(temp_file: Path):
137+
"""The default utf-8-sig decoding drops a byte-order mark that would otherwise break JSON parsers."""
138+
temp_file.write_bytes(codecs.BOM_UTF8 + b'{"a": 1}')
139+
resource = FileResource(uri=temp_file.as_uri(), path=temp_file, mime_type="application/json")
140+
assert await resource.read() == '{"a": 1}'
141+
142+
124143
@pytest.mark.anyio
125144
async def test_explicit_encoding_overrides_default(temp_file: Path):
126145
"""An explicit encoding wins over the mime-type default and is what decodes the file."""

0 commit comments

Comments
 (0)