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

This reverts commit 1efc4bc3ff.
This commit is contained in:
Jeremiah Lowin 2025-12-14 21:36:40 -05:00 committed by GitHub
commit 5f3bb05f99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 648 additions and 254 deletions

View file

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

View file

@ -2,10 +2,14 @@
from __future__ import annotations
import base64
import inspect
import warnings
from collections.abc import Callable
from typing import TYPE_CHECKING, Annotated, Any
from typing import Annotated, Any
import mcp.types
import pydantic
import pydantic_core
from mcp.types import Annotations, Icon
from mcp.types import Resource as SDKResource
@ -19,6 +23,7 @@ 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
@ -26,8 +31,103 @@ from fastmcp.utilities.types import (
get_fn_name,
)
if TYPE_CHECKING:
pass
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,
)
class Resource(FastMCPComponent):
@ -113,14 +213,41 @@ class Resource(FastMCPComponent):
raise ValueError("Either name or uri must be provided")
return self
async def read(self) -> str | bytes:
async def read(self) -> str | bytes | ResourceContent:
"""Read the resource content.
This method is not implemented in the base Resource class and must be
implemented by subclasses.
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.
"""
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,
*,
@ -224,17 +351,23 @@ class FunctionResource(Resource):
task_config=task_config,
)
async def read(self) -> str | bytes:
"""Read the resource by calling the wrapped function."""
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.
"""
# 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()
elif isinstance(result, bytes | str):
return result
else:
return pydantic_core.to_json(result, fallback=str).decode()
# Convert any value to ResourceContent
return ResourceContent.from_value(result, mime_type=self.mime_type)

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
from fastmcp.resources.resource import Resource, ResourceContent
from fastmcp.resources.template import (
ResourceTemplate,
match_uri_template,
@ -286,10 +286,14 @@ class ResourceManager:
raise NotFoundError(f"Unknown resource: {uri_str}")
async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
async def read_resource(self, uri: AnyUrl | str) -> ResourceContent:
"""
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)
@ -297,7 +301,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:
@ -321,7 +325,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
from fastmcp.resources.resource import Resource, ResourceContent
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) -> str:
async def read(self) -> ResourceContent:
"""Read the text content."""
return self.text
return ResourceContent(content=self.text, mime_type=self.mime_type)
class BinaryResource(Resource):
@ -33,9 +33,9 @@ class BinaryResource(Resource):
data: bytes = Field(description="Binary content of the resource")
async def read(self) -> bytes:
async def read(self) -> ResourceContent:
"""Read the binary content."""
return self.data
return ResourceContent(content=self.data, mime_type=self.mime_type)
class FileResource(Resource):
@ -76,12 +76,14 @@ class FileResource(Resource):
return not mime_type.startswith("text/")
@override
async def read(self) -> str | bytes:
async def read(self) -> ResourceContent:
"""Read the file content."""
try:
if self.is_binary:
return await self._async_path.read_bytes()
return await self._async_path.read_text()
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)
except Exception as e:
raise ResourceError(f"Error reading file {self.path}") from e
@ -95,12 +97,12 @@ class HttpResource(Resource):
)
@override
async def read(self) -> str | bytes:
async def read(self) -> ResourceContent:
"""Read the HTTP content."""
async with httpx.AsyncClient() as client:
response = await client.get(self.url)
_ = response.raise_for_status()
return response.text
return ResourceContent(content=response.text, mime_type=self.mime_type)
class DirectoryResource(Resource):
@ -145,13 +147,14 @@ class DirectoryResource(Resource):
raise ResourceError(f"Error listing directory {self.path}") from e
@override
async def read(self) -> str: # Always returns JSON string
async def read(self) -> ResourceContent:
"""Read the directory listing."""
try:
files: list[Path] = await self.list_files()
file_list = [str(f.relative_to(self.path)) for f in files]
return json.dumps({"files": file_list}, indent=2)
content = json.dumps({"files": file_list}, indent=2)
return ResourceContent(content=content, mime_type=self.mime_type)
except Exception as e:
raise ResourceError(f"Error reading directory {self.path}") from e

View file

@ -13,7 +13,6 @@ from typing import Any, Literal, cast, 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 (
@ -38,6 +37,7 @@ from starlette.requests import Request
from typing_extensions import TypeVar
from fastmcp import settings
from fastmcp.resources.resource import ResourceContent
from fastmcp.server.elicitation import (
AcceptedElicitation,
CancelledElicitation,
@ -294,17 +294,17 @@ class Context:
"""
return await self.fastmcp._get_prompt_mcp(name, arguments)
async def read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]:
async def read_resource(self, uri: str | AnyUrl) -> list[ResourceContent]:
"""Read a resource by URI.
Args:
uri: Resource URI to read
Returns:
The resource content as either text or bytes
List of ResourceContent objects
"""
# Context calls don't have task metadata, so always returns list
return await self.fastmcp._read_resource_mcp(uri) # type: ignore[return-value]
return await self.fastmcp._read_resource_mcp(uri)
async def log(
self,

View file

@ -14,12 +14,11 @@ 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
from fastmcp.resources.resource import Resource, ResourceContent
from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.utilities.logging import get_logger
@ -36,10 +35,11 @@ GLOBAL_KEY = "__global__"
class CachableReadResourceContents(BaseModel):
"""A wrapper for ReadResourceContents that can be cached."""
"""A wrapper for ResourceContent 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,13 +49,18 @@ class CachableReadResourceContents(BaseModel):
return sum(item.get_size() for item in values)
@classmethod
def wrap(cls, values: Sequence[ReadResourceContents]) -> list[Self]:
return [cls(content=item.content, mime_type=item.mime_type) for item in values]
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
]
@classmethod
def unwrap(cls, values: Sequence[Self]) -> list[ReadResourceContents]:
def unwrap(cls, values: Sequence[Self]) -> list[ResourceContent]:
return [
ReadResourceContents(content=item.content, mime_type=item.mime_type)
ResourceContent(
content=item.content, mime_type=item.mime_type, meta=item.meta
)
for item in values
]
@ -385,9 +390,9 @@ class ResponseCachingMiddleware(Middleware):
self,
context: MiddlewareContext[mcp.types.ReadResourceRequestParams],
call_next: CallNext[
mcp.types.ReadResourceRequestParams, Sequence[ReadResourceContents]
mcp.types.ReadResourceRequestParams, Sequence[ResourceContent]
],
) -> Sequence[ReadResourceContents]:
) -> Sequence[ResourceContent]:
"""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:
@ -399,7 +404,7 @@ class ResponseCachingMiddleware(Middleware):
if cached_value := await self._read_resource_cache.get(key=cache_key):
return CachableReadResourceContents.unwrap(values=cached_value)
value: Sequence[ReadResourceContents] = await call_next(context=context)
value: Sequence[ResourceContent] = await call_next(context=context)
cached_value = CachableReadResourceContents.wrap(values=value)
await self._read_resource_cache.put(

View file

@ -15,11 +15,10 @@ 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
from fastmcp.resources.resource import Resource, ResourceContent
from fastmcp.resources.template import ResourceTemplate
from fastmcp.tools.tool import Tool, ToolResult
@ -164,10 +163,8 @@ class Middleware:
async def on_read_resource(
self,
context: MiddlewareContext[mt.ReadResourceRequestParams],
call_next: CallNext[
mt.ReadResourceRequestParams, Sequence[ReadResourceContents]
],
) -> Sequence[ReadResourceContents]:
call_next: CallNext[mt.ReadResourceRequestParams, Sequence[ResourceContent]],
) -> Sequence[ResourceContent]:
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[ReadResourceContents]:
) -> list[ResourceContent]:
"""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, ResourceTemplate
from fastmcp.resources import Resource, ResourceContent, 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) -> str | bytes:
async def read(self) -> ResourceContent:
"""Fetch the resource data by making an HTTP request."""
try:
# Extract path parameters from the URI if present
@ -261,11 +261,15 @@ class OpenAPIResource(Resource):
if "application/json" in content_type:
result = response.json()
return json.dumps(result)
return ResourceContent(
content=json.dumps(result), mime_type="application/json"
)
elif any(ct in content_type for ct in ["text/", "application/xml"]):
return response.text
return ResourceContent(content=response.text, mime_type=self.mime_type)
else:
return response.content
return ResourceContent(
content=response.content, mime_type=self.mime_type
)
except httpx.HTTPStatusError as e:
# Handle HTTP errors (4xx, 5xx)

View file

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

View file

@ -32,7 +32,6 @@ 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
@ -63,7 +62,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
from fastmcp.resources.resource import FunctionResource, Resource, ResourceContent
from fastmcp.resources.resource_manager import ResourceManager
from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate
from fastmcp.server.auth import AuthProvider
@ -744,26 +743,7 @@ 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 = []
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",
)
)
mcp_contents = [item.to_mcp_resource_contents(uri) for item in result]
return mcp.types.ServerResult(
mcp.types.ReadResourceResult(
contents=mcp_contents,
@ -779,27 +759,7 @@ class FastMCP(Generic[LifespanResultT]):
if isinstance(result, mcp.types.ServerResult):
return 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",
)
)
mcp_contents = [item.to_mcp_resource_contents(uri) for item in result]
return mcp.types.ServerResult(
mcp.types.ReadResourceResult(contents=mcp_contents)
)
@ -1701,7 +1661,7 @@ class FastMCP(Generic[LifespanResultT]):
raise NotFoundError(f"Unknown tool: {tool_name!r}")
async def _read_resource_mcp(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
async def _read_resource_mcp(self, uri: AnyUrl | str) -> list[ResourceContent]:
"""
Handle MCP 'readResource' requests.
@ -1712,9 +1672,7 @@ class FastMCP(Generic[LifespanResultT]):
async with fastmcp.server.context.Context(fastmcp=self):
try:
# Task routing handled by custom handler
return list[ReadResourceContents](
await self._read_resource_middleware(uri)
)
return list[ResourceContent](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
@ -1725,7 +1683,7 @@ class FastMCP(Generic[LifespanResultT]):
async def _read_resource_middleware(
self,
uri: AnyUrl | str,
) -> list[ReadResourceContents]:
) -> list[ResourceContent]:
"""
Applies this server's middleware and delegates the filtered call to the manager.
"""
@ -1749,7 +1707,7 @@ class FastMCP(Generic[LifespanResultT]):
async def _read_resource(
self,
context: MiddlewareContext[mcp.types.ReadResourceRequestParams],
) -> list[ReadResourceContents]:
) -> list[ResourceContent]:
"""
Read a resource
"""
@ -1783,12 +1741,11 @@ 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)
return [
ReadResourceContents(
content=content,
mime_type=resource.mime_type,
)
]
# 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]
except NotFoundError:
pass

View file

@ -6,12 +6,13 @@ 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
@ -149,13 +150,16 @@ async def convert_prompt_result(
async def convert_resource_result(
server: FastMCP, raw_value: Any, uri: str, client_task_id: str
server: FastMCP,
raw_value: str | bytes | ResourceContent,
uri: str,
client_task_id: str,
) -> dict[str, Any]:
"""Convert raw resource return value to MCP resource contents dict.
"""Convert resource result to MCP resource contents dict.
Args:
server: FastMCP server instance
raw_value: The raw return value from user's resource function (str or bytes)
raw_value: Result from the resource function (str, bytes, or ResourceContent)
uri: Resource URI (for the contents response)
client_task_id: Client task ID for related-task metadata
@ -169,38 +173,32 @@ async def convert_resource_result(
}
}
# 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,
# 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",
}
else:
# Fallback: convert to JSON string
return {
"contents": [
{
"uri": uri,
"text": json.dumps(raw_value),
"mimeType": "application/json",
}
],
"_meta": related_task_meta,
content_dict = {
"uri": uri,
"blob": base64.b64encode(content).decode(),
"mimeType": mime_type or "application/octet-stream",
}
if content_meta:
content_dict["_meta"] = content_meta
return {
"contents": [content_dict],
"_meta": related_task_meta,
}

View file

@ -7,6 +7,7 @@ from pydantic import FileUrl
from fastmcp.exceptions import ResourceError
from fastmcp.resources import FileResource
from fastmcp.resources.resource import ResourceContent
@pytest.fixture
@ -61,9 +62,10 @@ class TestFileResource:
name="test",
path=temp_file,
)
content = await resource.read()
assert content == "test content"
assert resource.mime_type == "text/plain"
result = await resource.read()
assert isinstance(result, ResourceContent)
assert result.content == "test content"
assert result.mime_type == "text/plain"
async def test_read_binary_file(self, temp_file: Path):
"""Test reading a file as binary."""
@ -73,8 +75,9 @@ class TestFileResource:
path=temp_file,
is_binary=True,
)
content = await resource.read()
assert content == b"test content"
result = await resource.read()
assert isinstance(result, ResourceContent)
assert result.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
from fastmcp.resources.resource import FunctionResource, ResourceContent
class TestFunctionResource:
@ -36,9 +36,10 @@ class TestFunctionResource:
name="test",
fn=get_data,
)
content = await resource.read()
assert content == "Hello, world!"
assert resource.mime_type == "text/plain"
result = await resource.read()
assert isinstance(result, ResourceContent)
assert result.content == "Hello, world!"
assert result.mime_type == "text/plain"
async def test_read_binary(self):
"""Test reading binary data from a FunctionResource."""
@ -51,8 +52,9 @@ class TestFunctionResource:
name="test",
fn=get_data,
)
content = await resource.read()
assert content == b"Hello, world!"
result = await resource.read()
assert isinstance(result, ResourceContent)
assert result.content == b"Hello, world!"
async def test_json_conversion(self):
"""Test automatic JSON conversion of non-string results."""
@ -65,9 +67,10 @@ class TestFunctionResource:
name="test",
fn=get_data,
)
content = await resource.read()
assert isinstance(content, str)
assert '"key":"value"' in content
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert '"key":"value"' in result.content
async def test_error_handling(self):
"""Test error handling in FunctionResource."""
@ -94,8 +97,9 @@ class TestFunctionResource:
name="test",
fn=lambda: MyModel(name="test"),
)
content = await resource.read()
assert content == '{"name":"test"}'
result = await resource.read()
assert isinstance(result, ResourceContent)
assert result.content == '{"name":"test"}'
async def test_custom_type_conversion(self):
"""Test handling of custom types."""
@ -112,8 +116,9 @@ class TestFunctionResource:
name="test",
fn=get_data,
)
content = await resource.read()
assert isinstance(content, str)
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
async def test_async_read_text(self):
"""Test reading text from async FunctionResource."""
@ -126,6 +131,133 @@ class TestFunctionResource:
name="test",
fn=get_data,
)
content = await resource.read()
assert content == "Hello, world!"
assert resource.mime_type == "text/plain"
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

View file

@ -10,7 +10,7 @@ from fastmcp.resources import (
ResourceManager,
ResourceTemplate,
)
from fastmcp.resources.resource import FunctionResource
from fastmcp.resources.resource import FunctionResource, ResourceContent
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)
content = await resource.read()
assert content == "Hello, world!"
result = await resource.read()
assert result.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)
content = await resource.read()
assert content == "Hello, world!"
result = await resource.read()
assert result.content == "Hello, world!"
# Shouldn't work with the original template pattern
with pytest.raises(NotFoundError, match="Unknown resource"):
@ -591,12 +591,14 @@ class TestQueryOnlyTemplates:
# Should work without query param (uses default)
resource = await manager.get_resource("data://config")
content = await resource.read()
assert content == "Config in json format"
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert result.content == "Config in json format"
# Should also work via read_resource
content = await manager.read_resource("data://config")
assert content == "Config in json format"
result = await manager.read_resource("data://config")
assert result.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."""
@ -614,12 +616,14 @@ class TestQueryOnlyTemplates:
# Should work with query param (overrides default)
resource = await manager.get_resource("data://config?format=xml")
content = await resource.read()
assert content == "Config in xml format"
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert result.content == "Config in xml format"
# Should also work via read_resource
content = await manager.read_resource("data://config?format=xml")
assert content == "Config in xml format"
result = await manager.read_resource("data://config?format=xml")
assert result.content == "Config in xml format"
async def test_template_with_only_multiple_query_params(self):
"""Test template with only multiple query parameters."""
@ -636,16 +640,16 @@ class TestQueryOnlyTemplates:
manager.add_template(template)
# No query params - use all defaults
content = await manager.read_resource("data://items")
assert content == "Data in json (limit: 10)"
result = await manager.read_resource("data://items")
assert result.content == "Data in json (limit: 10)"
# Partial query params
content = await manager.read_resource("data://items?format=xml")
assert content == "Data in xml (limit: 10)"
result = await manager.read_resource("data://items?format=xml")
assert result.content == "Data in xml (limit: 10)"
# All query params
content = await manager.read_resource("data://items?format=xml&limit=20")
assert content == "Data in xml (limit: 20)"
result = await manager.read_resource("data://items?format=xml&limit=20")
assert result.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
from fastmcp.resources.resource import FunctionResource, ResourceContent
from fastmcp.resources.template import match_uri_template
@ -183,9 +183,9 @@ class TestResourceTemplate:
)
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert isinstance(content, str)
data = json.loads(content)
result = await resource.read()
assert isinstance(result.content, str)
data = json.loads(result.content)
assert data == {"key": "foo", "value": 123}
async def test_async_text_resource(self):
@ -206,8 +206,8 @@ class TestResourceTemplate:
)
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert content == "Hello, world!"
result = await resource.read()
assert result.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)
content = await resource.read()
assert content == b"test"
result = await resource.read()
assert result.content == b"test"
async def test_basemodel_conversion(self):
"""Test handling of BaseModel types."""
@ -252,9 +252,9 @@ class TestResourceTemplate:
)
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert isinstance(content, str)
data = json.loads(content)
result = await resource.read()
assert isinstance(result.content, str)
data = json.loads(result.content)
assert data == {"key": "foo", "value": 123}
async def test_custom_type_conversion(self):
@ -282,8 +282,8 @@ class TestResourceTemplate:
)
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert content == '"hello"'
result = await resource.read()
assert result.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)
content = await resource.read()
assert content == "X was foo"
result = await resource.read()
assert result.content == "X was foo"
class TestMatchUriTemplate:
@ -678,8 +678,8 @@ class TestContextHandling:
)
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert content == "42"
result = await resource.read()
assert result.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)
content = await resource.read()
assert content == "42"
result = await resource.read()
assert result.content == "42"
async def test_context_with_functools_wraps_decorator(self):
"""Regression test for #2524: decorated templates with Context should work."""
@ -733,8 +733,10 @@ class TestContextHandling:
async with context:
resource = await template.create_resource("test://42", {"item_id": 42})
content = await resource.read()
assert content == "item: 42"
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert result.content == "item: 42"
class TestQueryParameterExtraction:
@ -806,10 +808,11 @@ class TestQueryParameterTypeCoercion:
{"resource": "docs", "page": "5"},
)
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]
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
async def test_bool_coercion(self):
"""Test boolean type coercion for query parameters."""
@ -828,18 +831,20 @@ class TestQueryParameterTypeCoercion:
"config://feature?enabled=true",
{"name": "feature", "enabled": "true"},
)
content = await resource.read()
# TODO(ty): remove when ty supports `in` on str | bytes
assert '"enabled":true' in content # type: ignore[operator]
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert '"enabled":true' in result.content
# Test false value
resource = await template.create_resource(
"config://feature?enabled=false",
{"name": "feature", "enabled": "false"},
)
content = await resource.read()
# TODO(ty): remove when ty supports `in` on str | bytes
assert '"enabled":false' in content # type: ignore[operator]
result = await resource.read()
assert isinstance(result, ResourceContent)
assert isinstance(result.content, str)
assert '"enabled":false' in result.content
async def test_float_coercion(self):
"""Test float type coercion for query parameters."""
@ -862,10 +867,11 @@ class TestQueryParameterTypeCoercion:
{"service": "api", "threshold": "0.95"},
)
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]
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
class TestQueryParameterValidation:
@ -923,10 +929,11 @@ class TestQueryParameterWithDefaults:
{"id": "123"},
)
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]
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
async def test_partial_query_params(self):
"""Test providing only some query parameters."""
@ -948,11 +955,12 @@ class TestQueryParameterWithDefaults:
{"id": "123", "limit": "20"},
)
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
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
class TestQueryParameterWithWildcards:
@ -984,8 +992,9 @@ class TestQueryParameterWithWildcards:
{"path": "src/test/data.txt", "lines": "50"},
)
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
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

View file

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

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, ResourceTemplate
from fastmcp.resources import Resource, ResourceContent, ResourceTemplate
from fastmcp.server.server import (
add_resource_prefix,
has_resource_prefix,
@ -616,6 +616,59 @@ 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):