diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx index 86c043d351..2e81127a74 100644 --- a/docs/servers/resources.mdx +++ b/docs/servers/resources.mdx @@ -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 + + + +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 ebacf5ecf0..5462cd4a84 100644 --- a/src/fastmcp/resources/__init__.py +++ b/src/fastmcp/resources/__init__.py @@ -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 @@ HttpResource, TextResource, ) -from .resource_manager import ResourceManager __all__ = [ "BinaryResource", @@ -16,6 +16,7 @@ "FunctionResource", "HttpResource", "Resource", + "ResourceContent", "ResourceManager", "ResourceTemplate", "TextResource", diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index b6fbc90615..4d905f4862 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -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 MCPResource @@ -19,6 +23,7 @@ ) 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 @@ 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="", + 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 @@ def set_default_name(self) -> Self: 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 @@ def from_function( 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) diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py index 7367fb3e9c..ebc44ca8e9 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -11,7 +11,7 @@ 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 @@ async def get_resource(self, uri: AnyUrl | str) -> Resource: 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 @@ async def read_resource(self, uri: AnyUrl | str) -> str | bytes: 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 @@ async def read_resource(self, uri: AnyUrl | str) -> str | bytes: 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 5af01035ac..0d2e647dd4 100644 --- a/src/fastmcp/resources/types.py +++ b/src/fastmcp/resources/types.py @@ -12,7 +12,7 @@ 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 @@ def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> boo 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 @@ async def list_files(self) -> list[Path]: 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 diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index 393e088534..fb02335920 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -13,7 +13,6 @@ 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 ( @@ -36,6 +35,7 @@ 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 @@ async def get_prompt( """ 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, diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index 1ae232ec44..1f6ebdb594 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -14,12 +14,11 @@ 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 @@ 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 @@ def get_sizes(cls, values: Sequence[Self]) -> int: 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 @@ async def on_read_resource( 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 @@ async def on_read_resource( 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( diff --git a/src/fastmcp/server/middleware/middleware.py b/src/fastmcp/server/middleware/middleware.py index f1552bed40..e0a06642da 100644 --- a/src/fastmcp/server/middleware/middleware.py +++ b/src/fastmcp/server/middleware/middleware.py @@ -15,11 +15,10 @@ ) 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 @@ async def on_call_tool( 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( diff --git a/src/fastmcp/server/middleware/tool_injection.py b/src/fastmcp/server/middleware/tool_injection.py index 7914c5eca7..3c49272a8c 100644 --- a/src/fastmcp/server/middleware/tool_injection.py +++ b/src/fastmcp/server/middleware/tool_injection.py @@ -5,11 +5,11 @@ 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 @@ async def list_resources(context: Context) -> list[mcp.types.Resource]: 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) diff --git a/src/fastmcp/server/openapi/components.py b/src/fastmcp/server/openapi/components.py index 40577cfa78..d2d78a5a29 100644 --- a/src/fastmcp/server/openapi/components.py +++ b/src/fastmcp/server/openapi/components.py @@ -9,7 +9,7 @@ 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 @@ def __repr__(self) -> str: """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 @@ async def read(self) -> str | bytes: 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) diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index c388c4d3c8..dfa8b6844b 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -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.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 @@ async def list_resource_templates(self) -> list[ResourceTemplate]: 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 @@ async def read_resource(self, uri: AnyUrl | str) -> str | bytes: 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 @@ def from_mcp_resource( _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 @@ async def create_resource( 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 @@ async def create_resource( icons=self.icons, meta=self.meta, tags=(self.meta or {}).get("_fastmcp", {}).get("tags", []), - _value=value, + _cached_content=cached_content, ) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 7a55bbb230..e4ccc1543c 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -32,7 +32,6 @@ 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.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 @@ -736,26 +735,7 @@ async def handler(req: mcp.types.ReadResourceRequest) -> mcp.types.ServerResult: # 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, @@ -771,27 +751,7 @@ async def handler(req: mcp.types.ReadResourceRequest) -> mcp.types.ServerResult: 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) ) @@ -1664,7 +1624,7 @@ async def _call_tool( 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. @@ -1675,9 +1635,7 @@ async def _read_resource_mcp(self, uri: AnyUrl | str) -> list[ReadResourceConten 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 @@ -1688,7 +1646,7 @@ async def _read_resource_mcp(self, uri: AnyUrl | str) -> list[ReadResourceConten 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. """ @@ -1712,7 +1670,7 @@ async def _read_resource_middleware( async def _read_resource( self, context: MiddlewareContext[mcp.types.ReadResourceRequestParams], - ) -> list[ReadResourceContents]: + ) -> list[ResourceContent]: """ Read a resource """ @@ -1746,12 +1704,11 @@ async def _read_resource( 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 diff --git a/src/fastmcp/server/tasks/converters.py b/src/fastmcp/server/tasks/converters.py index 5bb6d5ae09..42ea1abed9 100644 --- a/src/fastmcp/server/tasks/converters.py +++ b/src/fastmcp/server/tasks/converters.py @@ -6,12 +6,13 @@ 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, + } diff --git a/tests/resources/test_file_resources.py b/tests/resources/test_file_resources.py index 05ba0fe753..1f4a8bf2c1 100644 --- a/tests/resources/test_file_resources.py +++ b/tests/resources/test_file_resources.py @@ -7,6 +7,7 @@ from fastmcp.exceptions import ResourceError from fastmcp.resources import FileResource +from fastmcp.resources.resource import ResourceContent @pytest.fixture @@ -61,9 +62,10 @@ async def test_read_text_file(self, temp_file: Path): 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 @@ async def test_read_binary_file(self, temp_file: Path): 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.""" diff --git a/tests/resources/test_function_resources.py b/tests/resources/test_function_resources.py index c6c0696d2a..9316c56af4 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 +from fastmcp.resources.resource import FunctionResource, ResourceContent class TestFunctionResource: @@ -36,9 +36,10 @@ def get_data() -> str: 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 @@ def get_data() -> bytes: 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 @@ def get_data() -> dict: 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 MyModel(BaseModel): 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 @@ def get_data() -> CustomData: 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 @@ async def get_data() -> str: 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 diff --git a/tests/resources/test_resource_manager.py b/tests/resources/test_resource_manager.py index c895cf739f..ecbf10cfd8 100644 --- a/tests/resources/test_resource_manager.py +++ b/tests/resources/test_resource_manager.py @@ -10,7 +10,7 @@ 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 @@ def greet(name: str) -> str: 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 @@ def greet(name: str) -> str: # 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 @@ def get_config(format: str = "json") -> str: # 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 @@ def get_config(format: str = "json") -> str: # 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 @@ def get_data(format: str = "json", limit: int = 10) -> str: 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. diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index ea6cf0e436..aff30ef7cb 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -7,7 +7,7 @@ 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 @@ def my_func(key: str, value: int) -> dict: ) 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 @@ async def greet(name: str) -> str: ) 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 @@ async def get_bytes(value: str) -> bytes: ) 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 @@ def get_data(key: str, value: int) -> MyModel: ) 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 @@ def get_data(value: str) -> CustomData: ) 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 @@ def __call__(self, x: str) -> str: ) 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 @@ def resource_with_context(x: int, ctx: Context) -> str: ) 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 @@ def resource_with_context(x: int, ctx: Context | None = None) -> str: ) 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 @@ async def decorated_template(ctx: Context, item_id: int) -> str: 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 @@ def get_page(resource: str, page: int = 1) -> dict: {"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 @@ def get_config(name: str, enabled: bool = False) -> dict: "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 @@ def get_metrics(service: str, threshold: float = 0.5) -> dict: {"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 @@ def get_data(id: str, format: str = "json", verbose: bool = False) -> dict: {"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 @@ def get_data( {"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 @@ def get_file(path: str, encoding: str = "utf-8", lines: int = 100) -> dict: {"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 diff --git a/tests/server/middleware/test_tool_injection.py b/tests/server/middleware/test_tool_injection.py index 505d78b018..86ff3e081d 100644 --- a/tests/server/middleware/test_tool_injection.py +++ b/tests/server/middleware/test_tool_injection.py @@ -489,10 +489,14 @@ async def test_read_resource_tool_works(self, server_with_resources: FastMCP): [ 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} + ] + } ) diff --git a/tests/server/test_server.py b/tests/server/test_server.py index ed95aa59e4..dfe7a645ba 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -10,7 +10,7 @@ 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 @@ def get_data() -> str: 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):