From 1efc4bc3ff01190cead79726bca2a9243027b482 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sat, 13 Dec 2025 15:02:18 -0500
Subject: [PATCH] Revert "Refactor resource behavior and add meta support
(#2598)" (#2609)
This reverts commit 12f2422e18485b814207ba9c1c940645caa9ebea.
---
docs/servers/resources.mdx | 51 ------
src/fastmcp/resources/__init__.py | 5 +-
src/fastmcp/resources/resource.py | 157 ++---------------
src/fastmcp/resources/resource_manager.py | 12 +-
src/fastmcp/resources/types.py | 27 ++-
src/fastmcp/server/context.py | 8 +-
src/fastmcp/server/middleware/caching.py | 25 ++-
src/fastmcp/server/middleware/middleware.py | 9 +-
.../server/middleware/tool_injection.py | 4 +-
src/fastmcp/server/openapi/components.py | 14 +-
src/fastmcp/server/proxy.py | 64 ++-----
src/fastmcp/server/server.py | 67 +++++--
src/fastmcp/server/tasks/converters.py | 70 ++++----
tests/resources/test_file_resources.py | 13 +-
tests/resources/test_function_resources.py | 164 ++----------------
tests/resources/test_resource_manager.py | 42 ++---
tests/resources/test_resource_template.py | 107 ++++++------
.../server/middleware/test_tool_injection.py | 8 +-
tests/server/test_server.py | 55 +-----
19 files changed, 254 insertions(+), 648 deletions(-)
diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx
index 2e81127a7..86c043d35 100644
--- a/docs/servers/resources.mdx
+++ b/docs/servers/resources.mdx
@@ -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
-
-
-
-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="
My Widget",
- 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"}
- )
-```
-
-
-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).
-
-
-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
diff --git a/src/fastmcp/resources/__init__.py b/src/fastmcp/resources/__init__.py
index 5462cd4a8..ebacf5ecf 100644
--- a/src/fastmcp/resources/__init__.py
+++ b/src/fastmcp/resources/__init__.py
@@ -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",
diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py
index 4d905f486..b6fbc9061 100644
--- a/src/fastmcp/resources/resource.py
+++ b/src/fastmcp/resources/resource.py
@@ -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="",
- 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()
diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py
index ebc44ca8e..7367fb3e9 100644
--- a/src/fastmcp/resources/resource_manager.py
+++ b/src/fastmcp/resources/resource_manager.py
@@ -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}"
diff --git a/src/fastmcp/resources/types.py b/src/fastmcp/resources/types.py
index 0d2e647dd..5af01035a 100644
--- a/src/fastmcp/resources/types.py
+++ b/src/fastmcp/resources/types.py
@@ -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
diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py
index fb0233592..393e08853 100644
--- a/src/fastmcp/server/context.py
+++ b/src/fastmcp/server/context.py
@@ -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,
diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py
index 1f6ebdb59..1ae232ec4 100644
--- a/src/fastmcp/server/middleware/caching.py
+++ b/src/fastmcp/server/middleware/caching.py
@@ -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(
diff --git a/src/fastmcp/server/middleware/middleware.py b/src/fastmcp/server/middleware/middleware.py
index e0a06642d..f1552bed4 100644
--- a/src/fastmcp/server/middleware/middleware.py
+++ b/src/fastmcp/server/middleware/middleware.py
@@ -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(
diff --git a/src/fastmcp/server/middleware/tool_injection.py b/src/fastmcp/server/middleware/tool_injection.py
index 3c49272a8..7914c5eca 100644
--- a/src/fastmcp/server/middleware/tool_injection.py
+++ b/src/fastmcp/server/middleware/tool_injection.py
@@ -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)
diff --git a/src/fastmcp/server/openapi/components.py b/src/fastmcp/server/openapi/components.py
index d2d78a5a2..40577cfa7 100644
--- a/src/fastmcp/server/openapi/components.py
+++ b/src/fastmcp/server/openapi/components.py
@@ -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)
diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py
index dfa8b6844..c388c4d3c 100644
--- a/src/fastmcp/server/proxy.py
+++ b/src/fastmcp/server/proxy.py
@@ -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,
)
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index e4ccc1543..7a55bbb23 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -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
diff --git a/src/fastmcp/server/tasks/converters.py b/src/fastmcp/server/tasks/converters.py
index 42ea1abed..5bb6d5ae0 100644
--- a/src/fastmcp/server/tasks/converters.py
+++ b/src/fastmcp/server/tasks/converters.py
@@ -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,
- }
diff --git a/tests/resources/test_file_resources.py b/tests/resources/test_file_resources.py
index 1f4a8bf2c..05ba0fe75 100644
--- a/tests/resources/test_file_resources.py
+++ b/tests/resources/test_file_resources.py
@@ -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."""
diff --git a/tests/resources/test_function_resources.py b/tests/resources/test_function_resources.py
index 9316c56af..c6c0696d2 100644
--- a/tests/resources/test_function_resources.py
+++ b/tests/resources/test_function_resources.py
@@ -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"
diff --git a/tests/resources/test_resource_manager.py b/tests/resources/test_resource_manager.py
index ecbf10cfd..c895cf739 100644
--- a/tests/resources/test_resource_manager.py
+++ b/tests/resources/test_resource_manager.py
@@ -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.
diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py
index aff30ef7c..ea6cf0e43 100644
--- a/tests/resources/test_resource_template.py
+++ b/tests/resources/test_resource_template.py
@@ -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
diff --git a/tests/server/middleware/test_tool_injection.py b/tests/server/middleware/test_tool_injection.py
index 86ff3e081..505d78b01 100644
--- a/tests/server/middleware/test_tool_injection.py
+++ b/tests/server/middleware/test_tool_injection.py
@@ -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"}]}
)
diff --git a/tests/server/test_server.py b/tests/server/test_server.py
index dfe7a645b..ed95aa59e 100644
--- a/tests/server/test_server.py
+++ b/tests/server/test_server.py
@@ -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="content",
- 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 == "content" # 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):