Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,31 @@

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

### `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"` 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.

Check warning on line 833 in docs/migration.md

View check run for this annotation

Claude / Claude Code Review

Documented textual-mime-type list omits application/ecmascript

The default-encoding matrix documented here presents itself as exhaustive, but `_TEXTUAL_APPLICATION_TYPES` in `src/mcp/server/mcpserver/resources/types.py` also includes `application/ecmascript`, which defaults to `encoding="utf-8"` (text) — per this doc it would be a blob. Either add `application/ecmascript` to this list (and consider the `FileResource` docstring), or drop it from the frozenset; it's also absent from the test parametrization in `test_textual_mime_types_default_to_utf8`.
Comment thread
maxisbey marked this conversation as resolved.
Outdated
Comment thread
claude[bot] marked this conversation as resolved.
Outdated

Passing the removed `is_binary=` argument raises a `ValidationError` at construction rather than being silently ignored — all `Resource` classes now reject unknown keyword arguments.

**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
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 template matching has been rewritten with [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) support.
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server/mcpserver/resources/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
class Resource(BaseModel, abc.ABC):
"""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")
name: str | None = Field(description="Name of the resource", default=None)
Expand Down
58 changes: 38 additions & 20 deletions src/mcp/server/mcpserver/resources/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
from collections.abc import Callable
from functools import partial
from pathlib import Path
from typing import Any

Expand All @@ -13,12 +14,38 @@
import pydantic
import pydantic_core
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.shared._callable_inspection import is_async_callable
from mcp.shared.exceptions import MCPError

_TEXTUAL_APPLICATION_TYPES = frozenset(
{
"application/json",
"application/xml",
"application/javascript",
"application/ecmascript",
}
)


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, JavaScript) are UTF-8 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"
return None


class TextResource(Resource):
"""A resource that reads from a string."""
Expand Down Expand Up @@ -122,17 +149,17 @@ def from_function(
class FileResource(Resource):
"""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"` for textual
mime types (`text/*`, JSON, XML, JavaScript) and None for everything else;
pass it explicitly to override either way.
"""

path: Path = Field(description="Path to the file")
is_binary: bool = Field(
default=False,
description="Whether to read the file as binary data",
)
mime_type: str = Field(
default="text/plain",
description="MIME type of the resource content",
encoding: str | None = Field(
default_factory=lambda data: _default_file_encoding(data["mime_type"]),
description="Text encoding used to decode the file, or None to serve its bytes as a blob",
)

@pydantic.field_validator("path")
Expand All @@ -143,21 +170,12 @@ def validate_absolute_path(cls, path: Path) -> Path:
raise ValueError("Path must be absolute")
return path

@pydantic.field_validator("is_binary")
@classmethod
def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool:
"""Set is_binary based on mime_type if not explicitly set."""
if is_binary:
return True
mime_type = info.data.get("mime_type", "text/plain")
return not mime_type.startswith("text/")

async def read(self) -> str | bytes:
"""Read the file content."""
try:
Comment thread
claude[bot] marked this conversation as resolved.
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_text)
return await anyio.to_thread.run_sync(partial(self.path.read_text, encoding=self.encoding))
except Exception as e:
raise ValueError(f"Error reading file {self.path}: {e}")

Expand Down
209 changes: 130 additions & 79 deletions tests/server/mcpserver/resources/test_file_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from tempfile import NamedTemporaryFile

import pytest
from pydantic import ValidationError

from mcp.server.mcpserver.resources import FileResource

Expand All @@ -24,93 +25,143 @@ def temp_file():
pass # File was already deleted by the test


class TestFileResource:
"""Test FileResource functionality."""
def test_file_resource_creation(temp_file: Path):
resource = FileResource(
uri=temp_file.as_uri(),
name="test",
description="test file",
path=temp_file,
)
assert str(resource.uri) == temp_file.as_uri()
assert resource.name == "test"
assert resource.description == "test file"
assert resource.mime_type == "text/plain"
assert resource.path == temp_file
assert resource.encoding == "utf-8"

def test_file_resource_creation(self, temp_file: Path):
"""Test creating a FileResource."""
resource = FileResource(
uri=temp_file.as_uri(),
name="test",
description="test file",
path=temp_file,
)
assert str(resource.uri) == temp_file.as_uri()
assert resource.name == "test"
assert resource.description == "test file"
assert resource.mime_type == "text/plain" # default
assert resource.path == temp_file
assert resource.is_binary is False # default

def test_file_resource_str_path_conversion(self, temp_file: Path):
"""Test FileResource handles string paths."""
resource = FileResource(
uri=f"file://{temp_file}",
name="test",
path=Path(str(temp_file)),
)
assert isinstance(resource.path, Path)
assert resource.path.is_absolute()

@pytest.mark.anyio
async def test_read_text_file(self, temp_file: Path):
"""Test reading a text file."""
resource = FileResource(
uri=f"file://{temp_file}",
def test_file_resource_str_path_conversion(temp_file: Path):
resource = FileResource(
uri=f"file://{temp_file}",
name="test",
path=Path(str(temp_file)),
)
assert isinstance(resource.path, Path)
assert resource.path.is_absolute()


@pytest.mark.anyio
async def test_read_text_file(temp_file: Path):
resource = FileResource(
uri=f"file://{temp_file}",
name="test",
path=temp_file,
)
content = await resource.read()
assert content == "test content"
assert resource.mime_type == "text/plain"


@pytest.mark.anyio
async def test_encoding_none_reads_bytes(temp_file: Path):
resource = FileResource(
uri=f"file://{temp_file}",
name="test",
path=temp_file,
encoding=None,
)
content = await resource.read()
assert isinstance(content, bytes)
assert content == b"test content"


@pytest.mark.parametrize(
"mime_type",
[
"text/plain",
"text/html; charset=utf-8",
"application/json",
"application/xml",
"application/vnd.api+json",
"image/svg+xml",
"application/javascript",
],
)
def test_textual_mime_types_default_to_utf8(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"


@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})


@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_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"):
FileResource(
uri="file:///test.txt",
name="test",
path=temp_file,
path=Path("test.txt"),
)
content = await resource.read()
assert content == "test content"
assert resource.mime_type == "text/plain"

@pytest.mark.anyio
async def test_read_binary_file(self, temp_file: Path):
"""Test reading a file as binary."""

@pytest.mark.anyio
async def test_missing_file_error(temp_file: Path):
missing = temp_file.parent / "missing.txt"
resource = FileResource(
uri="file:///missing.txt",
name="test",
path=missing,
)
with pytest.raises(ValueError, match="Error reading file"):
await resource.read()


@pytest.mark.skipif(os.name == "nt", reason="File permissions behave differently on Windows")
@pytest.mark.anyio
async def test_permission_error(temp_file: Path): # pragma: lax no cover
temp_file.chmod(0o000) # Remove all permissions
try:
resource = FileResource(
uri=f"file://{temp_file}",
uri=temp_file.as_uri(),
name="test",
path=temp_file,
is_binary=True,
)
content = await resource.read()
assert isinstance(content, bytes)
assert content == b"test content"

def test_relative_path_error(self):
"""Test error on relative path."""
with pytest.raises(ValueError, match="Path must be absolute"):
FileResource(
uri="file:///test.txt",
name="test",
path=Path("test.txt"),
)

@pytest.mark.anyio
async def test_missing_file_error(self, temp_file: Path):
"""Test error when file doesn't exist."""
# Create path to non-existent file
missing = temp_file.parent / "missing.txt"
resource = FileResource(
uri="file:///missing.txt",
name="test",
path=missing,
)
with pytest.raises(ValueError, match="Error reading file"):
await resource.read()

@pytest.mark.skipif(os.name == "nt", reason="File permissions behave differently on Windows")
@pytest.mark.anyio
async def test_permission_error(self, temp_file: Path): # pragma: lax no cover
"""Test reading a file without permissions."""
temp_file.chmod(0o000) # Remove all permissions
try:
resource = FileResource(
uri=temp_file.as_uri(),
name="test",
path=temp_file,
)
with pytest.raises(ValueError, match="Error reading file"):
await resource.read()
finally:
temp_file.chmod(0o644) # Restore permissions
finally:
temp_file.chmod(0o644) # Restore permissions
Loading