Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
31 changes: 31 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,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`).

### `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 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
65 changes: 47 additions & 18 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,34 @@
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

# `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):
"""A resource that reads from a string."""
Expand Down Expand Up @@ -122,17 +145,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-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")
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 +166,27 @@ def validate_absolute_path(cls, path: Path) -> Path:
raise ValueError("Path must be absolute")
return path

@pydantic.field_validator("is_binary")
@pydantic.field_validator("encoding")
@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/")
def validate_text_encoding(cls, encoding: str | None) -> str | None:
"""Ensure the encoding names a usable text codec, so a mistake fails at construction not at read."""
if encoding is not None:
# Decoding a probe byte rejects both unknown names and codecs that
# aren't text encodings (base64_codec, rot13, ...) via LookupError.
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:
"""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
Loading
Loading