Revert "Refactor resource behavior and add meta support (#2598)" (#2609)

This reverts commit 12f2422e18.
This commit is contained in:
Jeremiah Lowin 2025-12-13 15:02:18 -05:00 committed by GitHub
commit 1efc4bc3ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 254 additions and 648 deletions

View file

@ -138,59 +138,8 @@ FastMCP automatically converts your function's return value into the appropriate
- **`str`**: Sent as `TextResourceContents` (with `mime_type="text/plain"` by default).
- **`dict`, `list`, `pydantic.BaseModel`**: Automatically serialized to a JSON string and sent as `TextResourceContents` (with `mime_type="application/json"` by default).
- **`bytes`**: Base64 encoded and sent as `BlobResourceContents`. You should specify an appropriate `mime_type` (e.g., `"image/png"`, `"application/octet-stream"`).
- **`ResourceContent`**: Full control over content, MIME type, and metadata. See [ResourceContent](#resourcecontent) below.
- **`None`**: Results in an empty resource content list being returned.
#### ResourceContent
<VersionBadge version="2.14.1" />
For complete control over resource responses, return a `ResourceContent` object. This lets you include metadata alongside your resource content, which is useful for cases like Content Security Policy headers for HTML widgets.
```python
from fastmcp import FastMCP
from fastmcp.resources import ResourceContent
mcp = FastMCP(name="WidgetServer")
@mcp.resource("widget://my-widget")
def get_widget() -> ResourceContent:
"""Returns an HTML widget with CSP metadata."""
return ResourceContent(
content="<html><body>My Widget</body></html>",
mime_type="text/html",
meta={"csp": "script-src 'self'"}
)
```
`ResourceContent` accepts three fields:
**`content`** - The actual resource content. Can be `str` (text content) or `bytes` (binary content). This is the data that will be returned to the client.
**`mime_type`** - Optional MIME type for the content. Defaults to `"text/plain"` for string content and `"application/octet-stream"` for binary content.
**`meta`** - Optional metadata dictionary that will be included in the MCP response's `_meta` field. Use this for runtime metadata like Content Security Policy headers, caching hints, or other client-specific data.
```python
# Binary content with metadata
@mcp.resource("images://logo")
def get_logo() -> ResourceContent:
"""Returns a logo image with caching metadata."""
with open("logo.png", "rb") as f:
image_data = f.read()
return ResourceContent(
content=image_data,
mime_type="image/png",
meta={"cache-control": "max-age=3600"}
)
```
<Note>
The `meta` field in `ResourceContent` is for runtime metadata specific to this read response. This is separate from the `meta` parameter in `@mcp.resource(meta={...})`, which provides static metadata about the resource definition itself (returned when listing resources).
</Note>
You can still return plain `str` or `bytes` from your resource functions—`ResourceContent` is opt-in for when you need to include metadata.
### Disabling Resources
<VersionBadge version="2.8.0" />

View file

@ -1,5 +1,4 @@
from .resource import FunctionResource, Resource, ResourceContent
from .resource_manager import ResourceManager
from .resource import FunctionResource, Resource
from .template import ResourceTemplate
from .types import (
BinaryResource,
@ -8,6 +7,7 @@ from .types import (
HttpResource,
TextResource,
)
from .resource_manager import ResourceManager
__all__ = [
"BinaryResource",
@ -16,7 +16,6 @@ __all__ = [
"FunctionResource",
"HttpResource",
"Resource",
"ResourceContent",
"ResourceManager",
"ResourceTemplate",
"TextResource",

View file

@ -2,14 +2,10 @@
from __future__ import annotations
import base64
import inspect
import warnings
from collections.abc import Callable
from typing import Annotated, Any
from typing import TYPE_CHECKING, Annotated, Any
import mcp.types
import pydantic
import pydantic_core
from mcp.types import Annotations, Icon
from mcp.types import Resource as MCPResource
@ -23,7 +19,6 @@ from pydantic import (
)
from typing_extensions import Self
from fastmcp import settings
from fastmcp.server.dependencies import get_context, without_injected_parameters
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.utilities.components import FastMCPComponent
@ -31,103 +26,8 @@ from fastmcp.utilities.types import (
get_fn_name,
)
class ResourceContent(pydantic.BaseModel):
"""Canonical wrapper for resource content.
This is the internal representation for all resource reads. Users can
return ResourceContent directly for full control, or return simpler types
(str, bytes, dict) which will be automatically converted.
Example:
```python
from fastmcp import FastMCP
from fastmcp.resources import ResourceContent
mcp = FastMCP()
@mcp.resource("widget://my-widget")
def my_widget() -> ResourceContent:
return ResourceContent(
content="<widget html>",
meta={"csp": "script-src 'self'"}
)
```
"""
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
content: str | bytes
mime_type: str | None = None
meta: dict[str, Any] | None = None
@classmethod
def from_value(
cls,
value: Any,
mime_type: str | None = None,
meta: dict[str, Any] | None = None,
) -> ResourceContent:
"""Convert any value to ResourceContent, handling serialization.
Args:
value: The value to convert. Can be:
- ResourceContent: returned as-is (meta param ignored)
- str: text content
- bytes: binary content
- other: serialized to JSON string
mime_type: Optional MIME type override. If not provided:
- str "text/plain"
- bytes "application/octet-stream"
- other "application/json"
meta: Optional metadata (ignored if value is already ResourceContent)
Returns:
ResourceContent instance
"""
if isinstance(value, ResourceContent):
return value
if isinstance(value, str):
return cls(content=value, mime_type=mime_type or "text/plain", meta=meta)
if isinstance(value, bytes):
return cls(
content=value,
mime_type=mime_type or "application/octet-stream",
meta=meta,
)
# dict, list, BaseModel, etc → JSON
json_str = pydantic_core.to_json(value, fallback=str).decode()
return cls(
content=json_str, mime_type=mime_type or "application/json", meta=meta
)
def to_mcp_resource_contents(
self, uri: AnyUrl | str
) -> mcp.types.TextResourceContents | mcp.types.BlobResourceContents:
"""Convert to MCP resource contents type.
Args:
uri: The URI of the resource (required by MCP types)
Returns:
TextResourceContents for str content, BlobResourceContents for bytes
"""
if isinstance(self.content, str):
return mcp.types.TextResourceContents(
uri=AnyUrl(uri) if isinstance(uri, str) else uri,
text=self.content,
mimeType=self.mime_type or "text/plain",
_meta=self.meta,
)
else:
return mcp.types.BlobResourceContents(
uri=AnyUrl(uri) if isinstance(uri, str) else uri,
blob=base64.b64encode(self.content).decode(),
mimeType=self.mime_type or "application/octet-stream",
_meta=self.meta,
)
if TYPE_CHECKING:
pass
class Resource(FastMCPComponent):
@ -213,41 +113,14 @@ class Resource(FastMCPComponent):
raise ValueError("Either name or uri must be provided")
return self
async def read(self) -> str | bytes | ResourceContent:
async def read(self) -> str | bytes:
"""Read the resource content.
This method must be implemented by subclasses. For backwards compatibility,
subclasses can return str, bytes, or ResourceContent. However, returning
str or bytes is deprecated - new code should return ResourceContent.
Returns:
str | bytes | ResourceContent: The resource content. Returning str
or bytes is deprecated; prefer ResourceContent for full control
over MIME type and metadata.
This method is not implemented in the base Resource class and must be
implemented by subclasses.
"""
raise NotImplementedError("Subclasses must implement read()")
async def _read(self) -> ResourceContent:
"""Internal API that always returns ResourceContent.
This method calls read() and wraps str/bytes results in ResourceContent.
ResourceManager and other internal code should call this method instead
of read() directly.
"""
result = await self.read()
if isinstance(result, ResourceContent):
return result
# Deprecated in 2.14.1: returning str/bytes from read()
if settings.deprecation_warnings:
warnings.warn(
f"Resource.read() returning str or bytes is deprecated (since 2.14.1). "
f"Return ResourceContent instead. "
f"(Resource: {self.__class__.__name__}, URI: {self.uri})",
DeprecationWarning,
stacklevel=2,
)
return ResourceContent.from_value(result, mime_type=self.mime_type)
def to_mcp_resource(
self,
*,
@ -351,23 +224,17 @@ class FunctionResource(Resource):
task_config=task_config,
)
async def read(self) -> str | bytes | ResourceContent:
"""Read the resource by calling the wrapped function.
Returns:
str | bytes | ResourceContent: The resource content. If the user's
function returns str, bytes, dict, etc., it will be wrapped
in ResourceContent. Nested Resource reads may return raw types.
"""
async def read(self) -> str | bytes:
"""Read the resource by calling the wrapped function."""
# self.fn is wrapped by without_injected_parameters which handles
# dependency resolution internally
result = self.fn()
if inspect.isawaitable(result):
result = await result
# If user returned another Resource, read it recursively
if isinstance(result, Resource):
return await result.read()
# Convert any value to ResourceContent
return ResourceContent.from_value(result, mime_type=self.mime_type)
elif isinstance(result, bytes | str):
return result
else:
return pydantic_core.to_json(result, fallback=str).decode()

View file

@ -11,7 +11,7 @@ from pydantic import AnyUrl
from fastmcp import settings
from fastmcp.exceptions import NotFoundError, ResourceError
from fastmcp.resources.resource import Resource, ResourceContent
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import (
ResourceTemplate,
match_uri_template,
@ -286,14 +286,10 @@ class ResourceManager:
raise NotFoundError(f"Unknown resource: {uri_str}")
async def read_resource(self, uri: AnyUrl | str) -> ResourceContent:
async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
"""
Internal API for servers: Finds and reads a resource, respecting the
filtered protocol path.
Returns:
ResourceContent: The canonical content wrapper. All Resource.read()
implementations now return ResourceContent.
"""
uri_str = str(uri)
@ -301,7 +297,7 @@ class ResourceManager:
if uri_str in self._resources:
resource = await self.get_resource(uri_str)
try:
return await resource._read()
return await resource.read()
# raise ResourceErrors as-is
except ResourceError as e:
@ -325,7 +321,7 @@ class ResourceManager:
if (params := match_uri_template(uri_str, key)) is not None:
try:
resource = await template.create_resource(uri_str, params=params)
return await resource._read()
return await resource.read()
except ResourceError as e:
logger.exception(
f"Error reading resource from template {uri_str!r}"

View file

@ -12,7 +12,7 @@ from pydantic import Field, ValidationInfo
from typing_extensions import override
from fastmcp.exceptions import ResourceError
from fastmcp.resources.resource import Resource, ResourceContent
from fastmcp.resources.resource import Resource
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
@ -23,9 +23,9 @@ class TextResource(Resource):
text: str = Field(description="Text content of the resource")
async def read(self) -> ResourceContent:
async def read(self) -> str:
"""Read the text content."""
return ResourceContent(content=self.text, mime_type=self.mime_type)
return self.text
class BinaryResource(Resource):
@ -33,9 +33,9 @@ class BinaryResource(Resource):
data: bytes = Field(description="Binary content of the resource")
async def read(self) -> ResourceContent:
async def read(self) -> bytes:
"""Read the binary content."""
return ResourceContent(content=self.data, mime_type=self.mime_type)
return self.data
class FileResource(Resource):
@ -76,14 +76,12 @@ class FileResource(Resource):
return not mime_type.startswith("text/")
@override
async def read(self) -> ResourceContent:
async def read(self) -> str | bytes:
"""Read the file content."""
try:
if self.is_binary:
content: str | bytes = await self._async_path.read_bytes()
else:
content = await self._async_path.read_text()
return ResourceContent(content=content, mime_type=self.mime_type)
return await self._async_path.read_bytes()
return await self._async_path.read_text()
except Exception as e:
raise ResourceError(f"Error reading file {self.path}") from e
@ -97,12 +95,12 @@ class HttpResource(Resource):
)
@override
async def read(self) -> ResourceContent:
async def read(self) -> str | bytes:
"""Read the HTTP content."""
async with httpx.AsyncClient() as client:
response = await client.get(self.url)
_ = response.raise_for_status()
return ResourceContent(content=response.text, mime_type=self.mime_type)
return response.text
class DirectoryResource(Resource):
@ -147,14 +145,13 @@ class DirectoryResource(Resource):
raise ResourceError(f"Error listing directory {self.path}") from e
@override
async def read(self) -> ResourceContent:
async def read(self) -> str: # Always returns JSON string
"""Read the directory listing."""
try:
files: list[Path] = await self.list_files()
file_list = [str(f.relative_to(self.path)) for f in files]
content = json.dumps({"files": file_list}, indent=2)
return ResourceContent(content=content, mime_type=self.mime_type)
return json.dumps({"files": file_list}, indent=2)
except Exception as e:
raise ResourceError(f"Error reading directory {self.path}") from e

View file

@ -13,6 +13,7 @@ from typing import Any, overload
import anyio
from mcp import LoggingLevel, ServerSession
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.lowlevel.server import request_ctx
from mcp.shared.context import RequestContext
from mcp.types import (
@ -35,7 +36,6 @@ from pydantic.networks import AnyUrl
from starlette.requests import Request
from typing_extensions import TypeVar
from fastmcp.resources.resource import ResourceContent
from fastmcp.server.elicitation import (
AcceptedElicitation,
CancelledElicitation,
@ -275,17 +275,17 @@ class Context:
"""
return await self.fastmcp._get_prompt_mcp(name, arguments)
async def read_resource(self, uri: str | AnyUrl) -> list[ResourceContent]:
async def read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]:
"""Read a resource by URI.
Args:
uri: Resource URI to read
Returns:
List of ResourceContent objects
The resource content as either text or bytes
"""
# Context calls don't have task metadata, so always returns list
return await self.fastmcp._read_resource_mcp(uri)
return await self.fastmcp._read_resource_mcp(uri) # type: ignore[return-value]
async def log(
self,

View file

@ -14,11 +14,12 @@ from key_value.aio.wrappers.statistics import StatisticsWrapper
from key_value.aio.wrappers.statistics.wrapper import (
KVStoreCollectionStatistics,
)
from mcp.server.lowlevel.helper_types import ReadResourceContents
from pydantic import BaseModel, Field
from typing_extensions import NotRequired, Self, override
from fastmcp.prompts.prompt import Prompt
from fastmcp.resources.resource import Resource, ResourceContent
from fastmcp.resources.resource import Resource
from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.utilities.logging import get_logger
@ -35,11 +36,10 @@ GLOBAL_KEY = "__global__"
class CachableReadResourceContents(BaseModel):
"""A wrapper for ResourceContent that can be cached."""
"""A wrapper for ReadResourceContents that can be cached."""
content: str | bytes
mime_type: str | None = None
meta: dict[str, Any] | None = None
def get_size(self) -> int:
return len(self.model_dump_json())
@ -49,18 +49,13 @@ class CachableReadResourceContents(BaseModel):
return sum(item.get_size() for item in values)
@classmethod
def wrap(cls, values: Sequence[ResourceContent]) -> list[Self]:
return [
cls(content=item.content, mime_type=item.mime_type, meta=item.meta)
for item in values
]
def wrap(cls, values: Sequence[ReadResourceContents]) -> list[Self]:
return [cls(content=item.content, mime_type=item.mime_type) for item in values]
@classmethod
def unwrap(cls, values: Sequence[Self]) -> list[ResourceContent]:
def unwrap(cls, values: Sequence[Self]) -> list[ReadResourceContents]:
return [
ResourceContent(
content=item.content, mime_type=item.mime_type, meta=item.meta
)
ReadResourceContents(content=item.content, mime_type=item.mime_type)
for item in values
]
@ -390,9 +385,9 @@ class ResponseCachingMiddleware(Middleware):
self,
context: MiddlewareContext[mcp.types.ReadResourceRequestParams],
call_next: CallNext[
mcp.types.ReadResourceRequestParams, Sequence[ResourceContent]
mcp.types.ReadResourceRequestParams, Sequence[ReadResourceContents]
],
) -> Sequence[ResourceContent]:
) -> Sequence[ReadResourceContents]:
"""Read a resource from the cache, if caching is enabled, and the result is in the cache. Otherwise,
otherwise call the next middleware and store the result in the cache if caching is enabled."""
if self._read_resource_settings.get("enabled") is False:
@ -404,7 +399,7 @@ class ResponseCachingMiddleware(Middleware):
if cached_value := await self._read_resource_cache.get(key=cache_key):
return CachableReadResourceContents.unwrap(values=cached_value)
value: Sequence[ResourceContent] = await call_next(context=context)
value: Sequence[ReadResourceContents] = await call_next(context=context)
cached_value = CachableReadResourceContents.wrap(values=value)
await self._read_resource_cache.put(

View file

@ -15,10 +15,11 @@ from typing import (
)
import mcp.types as mt
from mcp.server.lowlevel.helper_types import ReadResourceContents
from typing_extensions import TypeVar
from fastmcp.prompts.prompt import Prompt
from fastmcp.resources.resource import Resource, ResourceContent
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.tools.tool import Tool, ToolResult
@ -163,8 +164,10 @@ class Middleware:
async def on_read_resource(
self,
context: MiddlewareContext[mt.ReadResourceRequestParams],
call_next: CallNext[mt.ReadResourceRequestParams, Sequence[ResourceContent]],
) -> Sequence[ResourceContent]:
call_next: CallNext[
mt.ReadResourceRequestParams, Sequence[ReadResourceContents]
],
) -> Sequence[ReadResourceContents]:
return await call_next(context)
async def on_get_prompt(

View file

@ -5,11 +5,11 @@ from logging import Logger
from typing import Annotated, Any
import mcp.types
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.types import Prompt
from pydantic import AnyUrl
from typing_extensions import override
from fastmcp.resources.resource import ResourceContent
from fastmcp.server.context import Context
from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext
from fastmcp.tools.tool import Tool, ToolResult
@ -98,7 +98,7 @@ list_resources_tool = Tool.from_function(
async def read_resource(
context: Context,
uri: Annotated[AnyUrl | str, "The URI of the resource to read."],
) -> list[ResourceContent]:
) -> list[ReadResourceContents]:
"""Read a resource available on the server."""
return await context.read_resource(uri=uri)

View file

@ -9,7 +9,7 @@ import httpx
from mcp.types import ToolAnnotations
from pydantic.networks import AnyUrl
from fastmcp.resources import Resource, ResourceContent, ResourceTemplate
from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.dependencies import get_http_headers
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.utilities.logging import get_logger
@ -187,7 +187,7 @@ class OpenAPIResource(Resource):
"""Custom representation to prevent recursion errors when printing."""
return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})"
async def read(self) -> ResourceContent:
async def read(self) -> str | bytes:
"""Fetch the resource data by making an HTTP request."""
try:
# Extract path parameters from the URI if present
@ -261,15 +261,11 @@ class OpenAPIResource(Resource):
if "application/json" in content_type:
result = response.json()
return ResourceContent(
content=json.dumps(result), mime_type="application/json"
)
return json.dumps(result)
elif any(ct in content_type for ct in ["text/", "application/xml"]):
return ResourceContent(content=response.text, mime_type=self.mime_type)
return response.text
else:
return ResourceContent(
content=response.content, mime_type=self.mime_type
)
return response.content
except httpx.HTTPStatusError as e:
# Handle HTTP errors (4xx, 5xx)

View file

@ -1,6 +1,5 @@
from __future__ import annotations
import base64
import inspect
from collections.abc import Awaitable, Callable
from pathlib import Path
@ -32,7 +31,6 @@ from fastmcp.prompts import Prompt, PromptMessage
from fastmcp.prompts.prompt import PromptArgument
from fastmcp.prompts.prompt_manager import PromptManager
from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.resources.resource import ResourceContent
from fastmcp.resources.resource_manager import ResourceManager
from fastmcp.server.context import Context
from fastmcp.server.dependencies import get_context
@ -186,7 +184,7 @@ class ProxyResourceManager(ResourceManager, ProxyManagerMixin):
templates_dict = await self.get_resource_templates()
return list(templates_dict.values())
async def read_resource(self, uri: AnyUrl | str) -> ResourceContent:
async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
"""Reads a resource, trying local/mounted first, then proxy if not found."""
try:
# First try local and mounted resources
@ -196,22 +194,10 @@ class ProxyResourceManager(ResourceManager, ProxyManagerMixin):
client = await self._get_client()
async with client:
result = await client.read_resource(uri)
if not result:
raise ResourceError(
f"Remote server returned empty content for {uri}"
) from None
if isinstance(result[0], TextResourceContents):
return ResourceContent(
content=result[0].text,
mime_type=result[0].mimeType,
meta=result[0].meta,
)
return result[0].text
elif isinstance(result[0], BlobResourceContents):
return ResourceContent(
content=base64.b64decode(result[0].blob),
mime_type=result[0].mimeType,
meta=result[0].meta,
)
return result[0].blob
else:
raise ResourceError(
f"Unsupported content type: {type(result[0])}"
@ -346,18 +332,18 @@ class ProxyResource(Resource, MirroredComponent):
task_config: TaskConfig = TaskConfig(mode="forbidden")
_client: Client
_cached_content: ResourceContent | None = None
_value: str | bytes | None = None
def __init__(
self,
client: Client,
*,
_cached_content: ResourceContent | None = None,
_value: str | bytes | None = None,
**kwargs,
):
super().__init__(**kwargs)
self._client = client
self._cached_content = _cached_content
self._value = _value
@classmethod
def from_mcp_resource(
@ -381,27 +367,17 @@ class ProxyResource(Resource, MirroredComponent):
_mirrored=True,
)
async def read(self) -> ResourceContent:
async def read(self) -> str | bytes:
"""Read the resource content from the remote server."""
if self._cached_content is not None:
return self._cached_content
if self._value is not None:
return self._value
async with self._client:
result = await self._client.read_resource(self.uri)
if not result:
raise ResourceError(f"Remote server returned empty content for {self.uri}")
if isinstance(result[0], TextResourceContents):
return ResourceContent(
content=result[0].text,
mime_type=result[0].mimeType,
meta=result[0].meta,
)
return result[0].text
elif isinstance(result[0], BlobResourceContents):
return ResourceContent(
content=base64.b64decode(result[0].blob),
mime_type=result[0].mimeType,
meta=result[0].meta,
)
return result[0].blob
else:
raise ResourceError(f"Unsupported content type: {type(result[0])}")
@ -453,22 +429,10 @@ class ProxyTemplate(ResourceTemplate, MirroredComponent):
async with self._client:
result = await self._client.read_resource(parameterized_uri)
if not result:
raise ResourceError(
f"Remote server returned empty content for {parameterized_uri}"
)
if isinstance(result[0], TextResourceContents):
cached_content = ResourceContent(
content=result[0].text,
mime_type=result[0].mimeType,
meta=result[0].meta,
)
value = result[0].text
elif isinstance(result[0], BlobResourceContents):
cached_content = ResourceContent(
content=base64.b64decode(result[0].blob),
mime_type=result[0].mimeType,
meta=result[0].meta,
)
value = result[0].blob
else:
raise ResourceError(f"Unsupported content type: {type(result[0])}")
@ -482,7 +446,7 @@ class ProxyTemplate(ResourceTemplate, MirroredComponent):
icons=self.icons,
meta=self.meta,
tags=(self.meta or {}).get("_fastmcp", {}).get("tags", []),
_cached_content=cached_content,
_value=value,
)

View file

@ -32,6 +32,7 @@ import httpx
import mcp.types
import uvicorn
from docket import Docket, Worker
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
from mcp.server.stdio import stdio_server
from mcp.shared.exceptions import McpError
@ -62,7 +63,7 @@ from fastmcp.mcp_config import MCPConfig
from fastmcp.prompts import Prompt
from fastmcp.prompts.prompt import FunctionPrompt
from fastmcp.prompts.prompt_manager import PromptManager
from fastmcp.resources.resource import FunctionResource, Resource, ResourceContent
from fastmcp.resources.resource import FunctionResource, Resource
from fastmcp.resources.resource_manager import ResourceManager
from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate
from fastmcp.server.auth import AuthProvider
@ -735,7 +736,26 @@ class FastMCP(Generic[LifespanResultT]):
# Graceful degradation: if we got here with task_meta, something went wrong
# (This should be unreachable now that forbidden raises)
if task_meta:
mcp_contents = [item.to_mcp_resource_contents(uri) for item in result]
mcp_contents = []
for item in result:
if isinstance(item.content, str):
mcp_contents.append(
mcp.types.TextResourceContents(
uri=uri,
text=item.content,
mimeType=item.mime_type or "text/plain",
)
)
elif isinstance(item.content, bytes):
import base64
mcp_contents.append(
mcp.types.BlobResourceContents(
uri=uri,
blob=base64.b64encode(item.content).decode(),
mimeType=item.mime_type or "application/octet-stream",
)
)
return mcp.types.ServerResult(
mcp.types.ReadResourceResult(
contents=mcp_contents,
@ -751,7 +771,27 @@ class FastMCP(Generic[LifespanResultT]):
if isinstance(result, mcp.types.ServerResult):
return result
mcp_contents = [item.to_mcp_resource_contents(uri) for item in result]
mcp_contents = []
for item in result:
if isinstance(item.content, str):
mcp_contents.append(
mcp.types.TextResourceContents(
uri=uri,
text=item.content,
mimeType=item.mime_type or "text/plain",
)
)
elif isinstance(item.content, bytes):
import base64
mcp_contents.append(
mcp.types.BlobResourceContents(
uri=uri,
blob=base64.b64encode(item.content).decode(),
mimeType=item.mime_type or "application/octet-stream",
)
)
return mcp.types.ServerResult(
mcp.types.ReadResourceResult(contents=mcp_contents)
)
@ -1624,7 +1664,7 @@ class FastMCP(Generic[LifespanResultT]):
raise NotFoundError(f"Unknown tool: {tool_name!r}")
async def _read_resource_mcp(self, uri: AnyUrl | str) -> list[ResourceContent]:
async def _read_resource_mcp(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
"""
Handle MCP 'readResource' requests.
@ -1635,7 +1675,9 @@ class FastMCP(Generic[LifespanResultT]):
async with fastmcp.server.context.Context(fastmcp=self):
try:
# Task routing handled by custom handler
return list[ResourceContent](await self._read_resource_middleware(uri))
return list[ReadResourceContents](
await self._read_resource_middleware(uri)
)
except DisabledError as e:
# convert to NotFoundError to avoid leaking resource presence
raise NotFoundError(f"Unknown resource: {str(uri)!r}") from e
@ -1646,7 +1688,7 @@ class FastMCP(Generic[LifespanResultT]):
async def _read_resource_middleware(
self,
uri: AnyUrl | str,
) -> list[ResourceContent]:
) -> list[ReadResourceContents]:
"""
Applies this server's middleware and delegates the filtered call to the manager.
"""
@ -1670,7 +1712,7 @@ class FastMCP(Generic[LifespanResultT]):
async def _read_resource(
self,
context: MiddlewareContext[mcp.types.ReadResourceRequestParams],
) -> list[ResourceContent]:
) -> list[ReadResourceContents]:
"""
Read a resource
"""
@ -1704,11 +1746,12 @@ class FastMCP(Generic[LifespanResultT]):
resource = await self._resource_manager.get_resource(uri_str)
if self._should_enable_component(resource):
content = await self._resource_manager.read_resource(uri_str)
# read_resource() always returns ResourceContent now
# Use mime_type from ResourceContent if set, otherwise from resource
if content.mime_type is None:
content.mime_type = resource.mime_type
return [content]
return [
ReadResourceContents(
content=content,
mime_type=resource.mime_type,
)
]
except NotFoundError:
pass

View file

@ -6,13 +6,12 @@ Converts raw task return values to MCP result types.
from __future__ import annotations
import base64
import json
from typing import TYPE_CHECKING, Any
import mcp.types
import pydantic_core
from fastmcp.resources.resource import ResourceContent
if TYPE_CHECKING:
from fastmcp.server.server import FastMCP
@ -150,16 +149,13 @@ async def convert_prompt_result(
async def convert_resource_result(
server: FastMCP,
raw_value: str | bytes | ResourceContent,
uri: str,
client_task_id: str,
server: FastMCP, raw_value: Any, uri: str, client_task_id: str
) -> dict[str, Any]:
"""Convert resource result to MCP resource contents dict.
"""Convert raw resource return value to MCP resource contents dict.
Args:
server: FastMCP server instance
raw_value: Result from the resource function (str, bytes, or ResourceContent)
raw_value: The raw return value from user's resource function (str or bytes)
uri: Resource URI (for the contents response)
client_task_id: Client task ID for related-task metadata
@ -173,32 +169,38 @@ async def convert_resource_result(
}
}
# Convert to ResourceContent if needed (handles str, bytes)
if not isinstance(raw_value, ResourceContent):
raw_value = ResourceContent.from_value(raw_value)
# Extract content from ResourceContent
content = raw_value.content
mime_type = raw_value.mime_type
content_meta = raw_value.meta
if isinstance(content, str):
content_dict: dict[str, Any] = {
"uri": uri,
"text": content,
"mimeType": mime_type or "text/plain",
# Resources return str or bytes directly
if isinstance(raw_value, str):
return {
"contents": [
{
"uri": uri,
"text": raw_value,
"mimeType": "text/plain",
}
],
"_meta": related_task_meta,
}
elif isinstance(raw_value, bytes):
return {
"contents": [
{
"uri": uri,
"blob": base64.b64encode(raw_value).decode(),
"mimeType": "application/octet-stream",
}
],
"_meta": related_task_meta,
}
else:
content_dict = {
"uri": uri,
"blob": base64.b64encode(content).decode(),
"mimeType": mime_type or "application/octet-stream",
# Fallback: convert to JSON string
return {
"contents": [
{
"uri": uri,
"text": json.dumps(raw_value),
"mimeType": "application/json",
}
],
"_meta": related_task_meta,
}
if content_meta:
content_dict["_meta"] = content_meta
return {
"contents": [content_dict],
"_meta": related_task_meta,
}

View file

@ -7,7 +7,6 @@ from pydantic import FileUrl
from fastmcp.exceptions import ResourceError
from fastmcp.resources import FileResource
from fastmcp.resources.resource import ResourceContent
@pytest.fixture
@ -62,10 +61,9 @@ class TestFileResource:
name="test",
path=temp_file,
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert result.content == "test content"
assert result.mime_type == "text/plain"
content = await resource.read()
assert content == "test content"
assert resource.mime_type == "text/plain"
async def test_read_binary_file(self, temp_file: Path):
"""Test reading a file as binary."""
@ -75,9 +73,8 @@ class TestFileResource:
path=temp_file,
is_binary=True,
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert result.content == b"test content"
content = await resource.read()
assert content == b"test content"
def test_relative_path_error(self):
"""Test error on relative path."""

View file

@ -1,7 +1,7 @@
import pytest
from pydantic import AnyUrl, BaseModel
from fastmcp.resources.resource import FunctionResource, ResourceContent
from fastmcp.resources.resource import FunctionResource
class TestFunctionResource:
@ -36,10 +36,9 @@ class TestFunctionResource:
name="test",
fn=get_data,
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert result.content == "Hello, world!"
assert result.mime_type == "text/plain"
content = await resource.read()
assert content == "Hello, world!"
assert resource.mime_type == "text/plain"
async def test_read_binary(self):
"""Test reading binary data from a FunctionResource."""
@ -52,9 +51,8 @@ class TestFunctionResource:
name="test",
fn=get_data,
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert result.content == b"Hello, world!"
content = await resource.read()
assert content == b"Hello, world!"
async def test_json_conversion(self):
"""Test automatic JSON conversion of non-string results."""
@ -67,10 +65,9 @@ class TestFunctionResource:
name="test",
fn=get_data,
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert '"key":"value"' in result.content
content = await resource.read()
assert isinstance(content, str)
assert '"key":"value"' in content
async def test_error_handling(self):
"""Test error handling in FunctionResource."""
@ -97,9 +94,8 @@ class TestFunctionResource:
name="test",
fn=lambda: MyModel(name="test"),
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert result.content == '{"name":"test"}'
content = await resource.read()
assert content == '{"name":"test"}'
async def test_custom_type_conversion(self):
"""Test handling of custom types."""
@ -116,9 +112,8 @@ class TestFunctionResource:
name="test",
fn=get_data,
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
content = await resource.read()
assert isinstance(content, str)
async def test_async_read_text(self):
"""Test reading text from async FunctionResource."""
@ -131,133 +126,6 @@ class TestFunctionResource:
name="test",
fn=get_data,
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert result.content == "Hello, world!"
assert result.mime_type == "text/plain"
async def test_resource_content_text(self):
"""Test returning ResourceContent with text content."""
def get_data() -> ResourceContent:
return ResourceContent(
content="Hello, world!",
mime_type="text/html",
meta={"csp": "script-src 'self'"},
)
resource = FunctionResource(
uri=AnyUrl("function://test"),
name="test",
fn=get_data,
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert result.content == "Hello, world!"
assert result.mime_type == "text/html"
assert result.meta == {"csp": "script-src 'self'"}
async def test_resource_content_binary(self):
"""Test returning ResourceContent with binary content."""
def get_data() -> ResourceContent:
return ResourceContent(
content=b"\x00\x01\x02",
mime_type="application/octet-stream",
)
resource = FunctionResource(
uri=AnyUrl("function://test"),
name="test",
fn=get_data,
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert result.content == b"\x00\x01\x02"
assert result.mime_type == "application/octet-stream"
assert result.meta is None
async def test_resource_content_without_meta(self):
"""Test returning ResourceContent without meta."""
def get_data() -> ResourceContent:
return ResourceContent(content="plain text")
resource = FunctionResource(
uri=AnyUrl("function://test"),
name="test",
fn=get_data,
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert result.content == "plain text"
assert result.mime_type is None
assert result.meta is None
async def test_async_resource_content(self):
"""Test async function returning ResourceContent."""
async def get_data() -> ResourceContent:
return ResourceContent(
content="async content",
meta={"key": "value"},
)
resource = FunctionResource(
uri=AnyUrl("function://test"),
name="test",
fn=get_data,
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert result.content == "async content"
assert result.meta == {"key": "value"}
class TestResourceContentToMcp:
"""Test ResourceContent.to_mcp_resource_contents method."""
def test_text_content_to_mcp(self):
"""Test converting text ResourceContent to MCP type."""
rc = ResourceContent(
content="hello world",
mime_type="text/html",
meta={"csp": "script-src 'self'"},
)
mcp_content = rc.to_mcp_resource_contents("resource://test")
assert hasattr(mcp_content, "text")
assert mcp_content.text == "hello world"
assert mcp_content.mimeType == "text/html"
assert mcp_content.meta == {"csp": "script-src 'self'"}
def test_binary_content_to_mcp(self):
"""Test converting binary ResourceContent to MCP type."""
rc = ResourceContent(
content=b"\x00\x01\x02",
mime_type="application/octet-stream",
meta={"encoding": "raw"},
)
mcp_content = rc.to_mcp_resource_contents("resource://test")
assert hasattr(mcp_content, "blob")
assert mcp_content.blob == "AAEC" # base64 of \x00\x01\x02
assert mcp_content.mimeType == "application/octet-stream"
assert mcp_content.meta == {"encoding": "raw"}
def test_default_mime_types(self):
"""Test default mime types are applied correctly."""
text_rc = ResourceContent(content="text")
text_mcp = text_rc.to_mcp_resource_contents("resource://test")
assert text_mcp.mimeType == "text/plain"
binary_rc = ResourceContent(content=b"binary")
binary_mcp = binary_rc.to_mcp_resource_contents("resource://test")
assert binary_mcp.mimeType == "application/octet-stream"
def test_none_meta(self):
"""Test that None meta is handled correctly."""
rc = ResourceContent(content="no meta")
mcp_content = rc.to_mcp_resource_contents("resource://test")
assert mcp_content.meta is None
content = await resource.read()
assert content == "Hello, world!"
assert resource.mime_type == "text/plain"

View file

@ -10,7 +10,7 @@ from fastmcp.resources import (
ResourceManager,
ResourceTemplate,
)
from fastmcp.resources.resource import FunctionResource, ResourceContent
from fastmcp.resources.resource import FunctionResource
from fastmcp.utilities.tests import caplog_for_fastmcp
@ -303,8 +303,8 @@ class TestResourceManager:
resource = await manager.get_resource(AnyUrl("greet://world"))
assert isinstance(resource, FunctionResource)
result = await resource.read()
assert result.content == "Hello, world!"
content = await resource.read()
assert content == "Hello, world!"
async def test_get_unknown_resource(self):
"""Test getting a non-existent resource."""
@ -559,8 +559,8 @@ class TestCustomResourceKeys:
# Using a URI that matches the custom key pattern
resource = await manager.get_resource("custom://greet/world")
assert isinstance(resource, FunctionResource)
result = await resource.read()
assert result.content == "Hello, world!"
content = await resource.read()
assert content == "Hello, world!"
# Shouldn't work with the original template pattern
with pytest.raises(NotFoundError, match="Unknown resource"):
@ -591,14 +591,12 @@ class TestQueryOnlyTemplates:
# Should work without query param (uses default)
resource = await manager.get_resource("data://config")
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert result.content == "Config in json format"
content = await resource.read()
assert content == "Config in json format"
# Should also work via read_resource
result = await manager.read_resource("data://config")
assert result.content == "Config in json format"
content = await manager.read_resource("data://config")
assert content == "Config in json format"
async def test_template_with_only_query_params_with_query_string(self):
"""Test that templates with only query params work with query string."""
@ -616,14 +614,12 @@ class TestQueryOnlyTemplates:
# Should work with query param (overrides default)
resource = await manager.get_resource("data://config?format=xml")
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert result.content == "Config in xml format"
content = await resource.read()
assert content == "Config in xml format"
# Should also work via read_resource
result = await manager.read_resource("data://config?format=xml")
assert result.content == "Config in xml format"
content = await manager.read_resource("data://config?format=xml")
assert content == "Config in xml format"
async def test_template_with_only_multiple_query_params(self):
"""Test template with only multiple query parameters."""
@ -640,16 +636,16 @@ class TestQueryOnlyTemplates:
manager.add_template(template)
# No query params - use all defaults
result = await manager.read_resource("data://items")
assert result.content == "Data in json (limit: 10)"
content = await manager.read_resource("data://items")
assert content == "Data in json (limit: 10)"
# Partial query params
result = await manager.read_resource("data://items?format=xml")
assert result.content == "Data in xml (limit: 10)"
content = await manager.read_resource("data://items?format=xml")
assert content == "Data in xml (limit: 10)"
# All query params
result = await manager.read_resource("data://items?format=xml&limit=20")
assert result.content == "Data in xml (limit: 20)"
content = await manager.read_resource("data://items?format=xml&limit=20")
assert content == "Data in xml (limit: 20)"
async def test_has_resource_with_query_only_template(self):
"""Test that has_resource() works with query-only templates.

View file

@ -7,7 +7,7 @@ from pydantic import BaseModel
from fastmcp import Context, FastMCP
from fastmcp.resources import ResourceTemplate
from fastmcp.resources.resource import FunctionResource, ResourceContent
from fastmcp.resources.resource import FunctionResource
from fastmcp.resources.template import match_uri_template
@ -183,9 +183,9 @@ class TestResourceTemplate:
)
assert isinstance(resource, FunctionResource)
result = await resource.read()
assert isinstance(result.content, str)
data = json.loads(result.content)
content = await resource.read()
assert isinstance(content, str)
data = json.loads(content)
assert data == {"key": "foo", "value": 123}
async def test_async_text_resource(self):
@ -206,8 +206,8 @@ class TestResourceTemplate:
)
assert isinstance(resource, FunctionResource)
result = await resource.read()
assert result.content == "Hello, world!"
content = await resource.read()
assert content == "Hello, world!"
async def test_async_binary_resource(self):
"""Test creating a binary resource from async function."""
@ -227,8 +227,8 @@ class TestResourceTemplate:
)
assert isinstance(resource, FunctionResource)
result = await resource.read()
assert result.content == b"test"
content = await resource.read()
assert content == b"test"
async def test_basemodel_conversion(self):
"""Test handling of BaseModel types."""
@ -252,9 +252,9 @@ class TestResourceTemplate:
)
assert isinstance(resource, FunctionResource)
result = await resource.read()
assert isinstance(result.content, str)
data = json.loads(result.content)
content = await resource.read()
assert isinstance(content, str)
data = json.loads(content)
assert data == {"key": "foo", "value": 123}
async def test_custom_type_conversion(self):
@ -282,8 +282,8 @@ class TestResourceTemplate:
)
assert isinstance(resource, FunctionResource)
result = await resource.read()
assert result.content == '"hello"'
content = await resource.read()
assert content == '"hello"'
async def test_wildcard_param_can_create_resource(self):
"""Test that wildcard parameters are valid."""
@ -392,8 +392,8 @@ class TestResourceTemplate:
)
assert isinstance(resource, FunctionResource)
result = await resource.read()
assert result.content == "X was foo"
content = await resource.read()
assert content == "X was foo"
class TestMatchUriTemplate:
@ -678,8 +678,8 @@ class TestContextHandling:
)
assert isinstance(resource, FunctionResource)
result = await resource.read()
assert result.content == "42"
content = await resource.read()
assert content == "42"
async def test_context_optional(self):
"""Test that context is optional when creating resources."""
@ -704,8 +704,8 @@ class TestContextHandling:
)
assert isinstance(resource, FunctionResource)
result = await resource.read()
assert result.content == "42"
content = await resource.read()
assert content == "42"
async def test_context_with_functools_wraps_decorator(self):
"""Regression test for #2524: decorated templates with Context should work."""
@ -733,10 +733,8 @@ class TestContextHandling:
async with context:
resource = await template.create_resource("test://42", {"item_id": 42})
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert result.content == "item: 42"
content = await resource.read()
assert content == "item: 42"
class TestQueryParameterExtraction:
@ -808,11 +806,10 @@ class TestQueryParameterTypeCoercion:
{"resource": "docs", "page": "5"},
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert '"page":5' in result.content
assert '"type":"int"' in result.content
content = await resource.read()
# TODO(ty): remove when ty supports `in` on str | bytes
assert '"page":5' in content # type: ignore[operator]
assert '"type":"int"' in content # type: ignore[operator]
async def test_bool_coercion(self):
"""Test boolean type coercion for query parameters."""
@ -831,20 +828,18 @@ class TestQueryParameterTypeCoercion:
"config://feature?enabled=true",
{"name": "feature", "enabled": "true"},
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert '"enabled":true' in result.content
content = await resource.read()
# TODO(ty): remove when ty supports `in` on str | bytes
assert '"enabled":true' in content # type: ignore[operator]
# Test false value
resource = await template.create_resource(
"config://feature?enabled=false",
{"name": "feature", "enabled": "false"},
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert '"enabled":false' in result.content
content = await resource.read()
# TODO(ty): remove when ty supports `in` on str | bytes
assert '"enabled":false' in content # type: ignore[operator]
async def test_float_coercion(self):
"""Test float type coercion for query parameters."""
@ -867,11 +862,10 @@ class TestQueryParameterTypeCoercion:
{"service": "api", "threshold": "0.95"},
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert '"threshold":0.95' in result.content
assert '"type":"float"' in result.content
content = await resource.read()
# TODO(ty): remove when ty supports `in` on str | bytes
assert '"threshold":0.95' in content # type: ignore[operator]
assert '"type":"float"' in content # type: ignore[operator]
class TestQueryParameterValidation:
@ -929,11 +923,10 @@ class TestQueryParameterWithDefaults:
{"id": "123"},
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert '"format":"json"' in result.content
assert '"verbose":false' in result.content
content = await resource.read()
# TODO(ty): remove when ty supports `in` on str | bytes
assert '"format":"json"' in content # type: ignore[operator]
assert '"verbose":false' in content # type: ignore[operator]
async def test_partial_query_params(self):
"""Test providing only some query parameters."""
@ -955,12 +948,11 @@ class TestQueryParameterWithDefaults:
{"id": "123", "limit": "20"},
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert '"format":"json"' in result.content # default
assert '"limit":20' in result.content # provided
assert '"offset":0' in result.content # default
content = await resource.read()
# TODO(ty): remove when ty supports `in` on str | bytes
assert '"format":"json"' in content # type: ignore[operator] # default
assert '"limit":20' in content # type: ignore[operator] # provided
assert '"offset":0' in content # type: ignore[operator] # default
class TestQueryParameterWithWildcards:
@ -992,9 +984,8 @@ class TestQueryParameterWithWildcards:
{"path": "src/test/data.txt", "lines": "50"},
)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert '"path":"src/test/data.txt"' in result.content
assert '"encoding":"utf-8"' in result.content # default
assert '"lines":50' in result.content # provided
content = await resource.read()
# TODO(ty): remove when ty supports `in` on str | bytes
assert '"path":"src/test/data.txt"' in content # type: ignore[operator]
assert '"encoding":"utf-8"' in content # type: ignore[operator] # default
assert '"lines":50' in content # type: ignore[operator] # provided

View file

@ -489,14 +489,10 @@ class TestResourceToolMiddleware:
[
TextContent(
type="text",
text='[{"content":"debug=true","mime_type":"text/plain","meta":null}]',
text='[{"content":"debug=true","mime_type":"text/plain"}]',
)
]
)
assert result.structured_content == snapshot(
{
"result": [
{"content": "debug=true", "mime_type": "text/plain", "meta": None}
]
}
{"result": [{"content": "debug=true", "mime_type": "text/plain"}]}
)

View file

@ -10,7 +10,7 @@ from pydantic import Field
from fastmcp import Client, FastMCP
from fastmcp.exceptions import NotFoundError
from fastmcp.prompts.prompt import FunctionPrompt, Prompt
from fastmcp.resources import Resource, ResourceContent, ResourceTemplate
from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.server import (
add_resource_prefix,
has_resource_prefix,
@ -616,59 +616,6 @@ class TestResourceDecorator:
assert resource.meta == meta_data
async def test_resource_content_with_meta_in_response(self):
"""Test that ResourceContent meta is passed through to MCP response."""
mcp = FastMCP()
@mcp.resource("resource://widget")
def get_widget() -> ResourceContent:
return ResourceContent(
content="<widget>content</widget>",
mime_type="text/html",
meta={"csp": "script-src 'self'", "version": "1.0"},
)
async with Client(mcp) as client:
result = await client.read_resource("resource://widget")
assert len(result) == 1
assert result[0].text == "<widget>content</widget>" # type: ignore[attr-defined]
assert result[0].mimeType == "text/html" # type: ignore[attr-defined]
# Meta should be in the response
assert result[0].meta == {"csp": "script-src 'self'", "version": "1.0"} # type: ignore[attr-defined]
async def test_resource_content_binary_with_meta(self):
"""Test that ResourceContent with binary content and meta works."""
mcp = FastMCP()
@mcp.resource("resource://binary")
def get_binary() -> ResourceContent:
return ResourceContent(
content=b"\x00\x01\x02",
meta={"encoding": "raw"},
)
async with Client(mcp) as client:
result = await client.read_resource("resource://binary")
assert len(result) == 1
# Binary content comes back as blob
assert hasattr(result[0], "blob")
assert result[0].meta == {"encoding": "raw"} # type: ignore[attr-defined]
async def test_resource_content_without_meta(self):
"""Test that ResourceContent without meta works (meta is None)."""
mcp = FastMCP()
@mcp.resource("resource://plain")
def get_plain() -> ResourceContent:
return ResourceContent(content="plain content")
async with Client(mcp) as client:
result = await client.read_resource("resource://plain")
assert len(result) == 1
assert result[0].text == "plain content" # type: ignore[attr-defined]
# Meta should be None
assert result[0].meta is None # type: ignore[attr-defined]
class TestTemplateDecorator:
async def test_template_decorator(self):