diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx index ea9d16f6d7..35b909693c 100644 --- a/docs/servers/resources.mdx +++ b/docs/servers/resources.mdx @@ -39,15 +39,15 @@ def get_greeting() -> str: """Provides a simple greeting message.""" return "Hello from FastMCP Resources!" -# Resource returning JSON data (dict is auto-serialized) +# Resource returning JSON data @mcp.resource("data://config") -def get_config() -> dict: +def get_config() -> str: """Provides application configuration as JSON.""" - return { + return json.dumps({ "theme": "dark", "version": "1.2.0", "features": ["tools", "resources"], - } + }) ``` **Key Concepts:** @@ -76,9 +76,9 @@ mcp = FastMCP(name="DataServer") tags={"monitoring", "status"}, # Categorization tags meta={"version": "2.1", "team": "infrastructure"} # Custom metadata ) -def get_application_status() -> dict: +def get_application_status() -> str: """Internal function description (ignored if description is provided above).""" - return {"status": "ok", "uptime": 12345, "version": mcp.settings.version} # Example usage + return json.dumps({"status": "ok", "uptime": 12345, "version": mcp.settings.version}) ``` @@ -134,33 +134,36 @@ def get_application_status() -> dict: ### Return Values -FastMCP automatically converts your function's return value into the appropriate MCP resource content: +Resource functions must return one of three types: - **`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. +- **`ResourceResult`**: Full control over contents, MIME types, and metadata. See [ResourceResult](#resourceresult) below. + + +To return structured data like dicts or lists, serialize them to JSON strings using `json.dumps()`. This explicit approach ensures your type checker catches errors during development rather than at runtime when a client reads the resource. + -#### ResourceContent +#### ResourceResult - + -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. +`ResourceResult` gives you explicit control over resource responses: multiple content items, per-item MIME types, and metadata at both the item and result level. ```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'"} +from fastmcp.resources import ResourceResult, ResourceContent + +mcp = FastMCP() + +@mcp.resource("data://users") +def get_users() -> ResourceResult: + return ResourceResult( + contents=[ + ResourceContent(content='[{"id": 1}]', mime_type="application/json"), + ResourceContent(content="# Users\n...", mime_type="text/markdown"), + ], + meta={"total": 1} ) ``` @@ -172,25 +175,33 @@ def get_widget() -> ResourceContent: **`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. +For simple cases, you can pass `str` or `bytes` directly to `ResourceResult`: + ```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"} - ) +return ResourceResult("plain text") # auto-converts to ResourceContent +return ResourceResult(b"\x00\x01\x02") # binary content ``` - -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). - + + + Content to return. Strings and bytes are wrapped in a single `ResourceContent`. Use a list of `ResourceContent` for multiple items or custom MIME types. + + + Result-level metadata, included in the MCP response's `_meta` field. + + -You can still return plain `str` or `bytes` from your resource functions—`ResourceContent` is opt-in for when you need to include metadata. + + + The content data. Strings and bytes pass through directly. Other types (dict, list, BaseModel) are automatically JSON-serialized. + + + MIME type. Defaults to `text/plain` for strings, `application/octet-stream` for bytes, `application/json` for serialized objects. + + + Item-level metadata for this specific content. + + ### Visibility Control @@ -234,20 +245,20 @@ from fastmcp import FastMCP, Context mcp = FastMCP(name="DataServer") @mcp.resource("resource://system-status") -async def get_system_status(ctx: Context) -> dict: +async def get_system_status(ctx: Context) -> str: """Provides system status information.""" - return { + return json.dumps({ "status": "operational", "request_id": ctx.request_id - } + }) @mcp.resource("resource://{name}/details") -async def get_details(name: str, ctx: Context) -> dict: +async def get_details(name: str, ctx: Context) -> str: """Get details for a specific name.""" - return { + return json.dumps({ "name": name, "accessed_at": ctx.request_id - } + }) ``` For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context). @@ -401,9 +412,9 @@ You can add annotations to a resource using the `annotations` parameter in the ` "idempotentHint": True } ) -def get_config() -> dict: +def get_config() -> str: """Get application configuration.""" - return {"version": "1.0", "debug": False} + return json.dumps({"version": "1.0", "debug": False}) ``` FastMCP supports these standard annotations: @@ -430,22 +441,21 @@ Functions with `*args` are not supported as resource templates. However, unlike Here is a complete example that shows how to define two resource templates: ```python +import json from fastmcp import FastMCP mcp = FastMCP(name="DataServer") # Template URI includes {city} placeholder @mcp.resource("weather://{city}/current") -def get_weather(city: str) -> dict: +def get_weather(city: str) -> str: """Provides weather information for a specific city.""" - # In a real implementation, this would call a weather API - # Here we're using simplified logic for example purposes - return { + return json.dumps({ "city": city.capitalize(), "temperature": 22, "condition": "Sunny", "unit": "celsius" - } + }) # Template with multiple parameters and annotations @mcp.resource( @@ -455,16 +465,15 @@ def get_weather(city: str) -> dict: "idempotentHint": True } ) -def get_repo_info(owner: str, repo: str) -> dict: +def get_repo_info(owner: str, repo: str) -> str: """Retrieves information about a GitHub repository.""" - # In a real implementation, this would call the GitHub API - return { + return json.dumps({ "owner": owner, "name": repo, "full_name": f"{owner}/{repo}", "stars": 120, "forks": 48 - } + }) ``` With these two templates defined, clients can request a variety of resources: diff --git a/src/fastmcp/resources/__init__.py b/src/fastmcp/resources/__init__.py index 6632102324..5048e95caa 100644 --- a/src/fastmcp/resources/__init__.py +++ b/src/fastmcp/resources/__init__.py @@ -1,4 +1,4 @@ -from .resource import FunctionResource, Resource, ResourceContent +from .resource import FunctionResource, Resource, ResourceContent, ResourceResult from .template import ResourceTemplate from .types import ( BinaryResource, @@ -16,6 +16,7 @@ "HttpResource", "Resource", "ResourceContent", + "ResourceResult", "ResourceTemplate", "TextResource", ] diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index bce843b0ab..c511088577 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -4,7 +4,6 @@ import base64 import inspect -import warnings from collections.abc import Callable from typing import TYPE_CHECKING, Annotated, Any, ClassVar @@ -27,7 +26,6 @@ ) from typing_extensions import Self -from fastmcp import settings from fastmcp.server.dependencies import without_injected_parameters from fastmcp.server.tasks.config import TaskConfig from fastmcp.utilities.components import FastMCPComponent @@ -37,74 +35,61 @@ class ResourceContent(pydantic.BaseModel): - """Canonical wrapper for resource content. + """Wrapper for resource content with optional MIME type and metadata. - 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. + Accepts any value for content - strings and bytes pass through directly, + other types (dict, list, BaseModel, etc.) are automatically JSON-serialized. Example: ```python - from fastmcp import FastMCP from fastmcp.resources import ResourceContent - mcp = FastMCP() + # String content + ResourceContent("plain text") - @mcp.resource("widget://my-widget") - def my_widget() -> ResourceContent: - return ResourceContent( - content="", - meta={"csp": "script-src 'self'"} - ) + # Binary content + ResourceContent(b"binary data", mime_type="application/octet-stream") + + # Auto-serialized to JSON + ResourceContent({"key": "value"}) + ResourceContent(["a", "b", "c"]) ``` """ - 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, + def __init__( + self, + content: Any, mime_type: str | None = None, meta: dict[str, Any] | None = None, - ) -> ResourceContent: - """Convert any value to ResourceContent, handling serialization. + **kwargs: Any, + ): + """Create ResourceContent with automatic 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 + content: The content value. str and bytes pass through directly. + Other types (dict, list, BaseModel) are JSON-serialized. + mime_type: Optional MIME type. Defaults based on content type: + str → "text/plain", bytes → "application/octet-stream", + other → "application/json" + meta: Optional metadata dictionary. """ - 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 + if isinstance(content, str): + normalized_content: str | bytes = content + mime_type = mime_type or "text/plain" + elif isinstance(content, bytes): + normalized_content = content + mime_type = mime_type or "application/octet-stream" + else: + # dict, list, BaseModel, etc → JSON + normalized_content = pydantic_core.to_json(content, fallback=str).decode() + mime_type = mime_type or "application/json" + + super().__init__( + content=normalized_content, mime_type=mime_type, meta=meta, **kwargs ) def to_mcp_resource_contents( @@ -134,6 +119,98 @@ def to_mcp_resource_contents( ) +class ResourceResult(pydantic.BaseModel): + """Canonical result type for resource reads. + + Provides explicit control over resource responses: multiple content items, + per-item MIME types, and metadata at both the item and result level. + + Accepts: + - str: Wrapped as single ResourceContent (text/plain) + - bytes: Wrapped as single ResourceContent (application/octet-stream) + - list[ResourceContent]: Used directly for multiple items or custom MIME types + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.resources import ResourceResult, ResourceContent + + mcp = FastMCP() + + # Simple string content + @mcp.resource("data://simple") + def get_simple() -> ResourceResult: + return ResourceResult("hello world") + + # Multiple items with custom MIME types + @mcp.resource("data://items") + def get_items() -> ResourceResult: + return ResourceResult( + contents=[ + ResourceContent({"key": "value"}), # auto-serialized to JSON + ResourceContent(b"binary data"), + ], + meta={"count": 2} + ) + ``` + """ + + contents: list[ResourceContent] + meta: dict[str, Any] | None = None + + def __init__( + self, + contents: str | bytes | list[ResourceContent], + meta: dict[str, Any] | None = None, + **kwargs: Any, + ): + """Create ResourceResult. + + Args: + contents: String, bytes, or list of ResourceContent objects. + meta: Optional metadata about the resource result. + """ + normalized = self._normalize_contents(contents) + super().__init__(contents=normalized, meta=meta, **kwargs) + + @staticmethod + def _normalize_contents( + contents: str | bytes | list[ResourceContent], + ) -> list[ResourceContent]: + """Normalize input to list[ResourceContent].""" + if isinstance(contents, str): + return [ResourceContent(contents)] + if isinstance(contents, bytes): + return [ResourceContent(contents)] + if isinstance(contents, list): + # Validate all items are ResourceContent + for i, item in enumerate(contents): + if not isinstance(item, ResourceContent): + raise TypeError( + f"contents[{i}] must be ResourceContent, got {type(item).__name__}. " + f"Use ResourceContent({item!r}) to wrap the value." + ) + return contents + raise TypeError( + f"contents must be str, bytes, or list[ResourceContent], got {type(contents).__name__}" + ) + + def to_mcp_result(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult: + """Convert to MCP ReadResourceResult. + + Args: + uri: The URI of the resource (required by MCP types) + + Returns: + MCP ReadResourceResult with converted contents + """ + mcp_contents = [item.to_mcp_resource_contents(uri) for item in self.contents] + return mcp.types.ReadResourceResult( + contents=mcp_contents, + _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field + ) + + class Resource(FastMCPComponent): """Base class for all resources.""" @@ -201,28 +278,35 @@ def set_default_name(self) -> Self: raise ValueError("Either name or uri must be provided") return self - async def read(self) -> str | bytes | ResourceContent: + async def read( + self, + ) -> str | bytes | ResourceResult: """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. + Subclasses implement this to return resource data. Supported return types: + - str: Text content + - bytes: Binary content + - ResourceResult: Full control over contents and result-level meta """ raise NotImplementedError("Subclasses must implement read()") - def convert_result(self, raw_value: Any) -> ResourceContent: - """Convert a raw return value to ResourceContent. + def convert_result(self, raw_value: Any) -> ResourceResult: + """Convert a raw result to ResourceResult. + + This is used in two contexts: + 1. In _read() to convert user function return values to ResourceResult + 2. In tasks_result_handler() to convert Docket task results to ResourceResult - Handles ResourceContent passthrough and converts raw values using mime_type. + Handles ResourceResult passthrough and converts raw values using + ResourceResult's normalization. """ - return ResourceContent.from_value(raw_value, mime_type=self.mime_type) + if isinstance(raw_value, ResourceResult): + return raw_value - async def _read(self) -> ResourceContent | mcp.types.CreateTaskResult: + # ResourceResult.__init__ handles all normalization + return ResourceResult(raw_value) + + async def _read(self) -> ResourceResult | mcp.types.CreateTaskResult: """Server entry point that handles task routing. This allows ANY Resource subclass to support background execution by setting @@ -243,19 +327,8 @@ async def _read(self) -> ResourceContent | mcp.types.CreateTaskResult: if task_result: return task_result - # Synchronous execution + # Synchronous execution - convert result to ResourceResult 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 self.convert_result(result) def to_mcp_resource( @@ -377,14 +450,10 @@ def from_function( 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 | ResourceResult: + """Read the resource by calling the wrapped function.""" # self.fn is wrapped by without_injected_parameters which handles # dependency resolution internally result = self.fn() @@ -395,7 +464,7 @@ async def read(self) -> str | bytes | ResourceContent: if isinstance(result, Resource): return await result.read() - return self.convert_result(result) + return result def register_with_docket(self, docket: Docket) -> None: """Register this resource with docket for background execution. diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 00b8d2b01c..3f73d512e3 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -21,7 +21,7 @@ validate_call, ) -from fastmcp.resources.resource import Resource, ResourceContent +from fastmcp.resources.resource import Resource, ResourceResult from fastmcp.server.dependencies import without_injected_parameters from fastmcp.server.tasks.config import TaskConfig from fastmcp.utilities.components import FastMCPComponent @@ -155,22 +155,31 @@ def matches(self, uri: str) -> dict[str, Any] | None: """Check if URI matches template and extract parameters.""" return match_uri_template(uri, self.uri_template) - async def read(self, arguments: dict[str, Any]) -> str | bytes: + async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult: """Read the resource content.""" raise NotImplementedError( "Subclasses must implement read() or override create_resource()" ) - def convert_result(self, raw_value: Any) -> ResourceContent: - """Convert a raw return value to ResourceContent. + def convert_result(self, raw_value: Any) -> ResourceResult: + """Convert a raw result to ResourceResult. - Handles ResourceContent passthrough and converts raw values using mime_type. + This is used in two contexts: + 1. In _read() to convert user function return values to ResourceResult + 2. In tasks_result_handler() to convert Docket task results to ResourceResult + + Handles ResourceResult passthrough and converts raw values using + ResourceResult's normalization. """ - return ResourceContent.from_value(raw_value, mime_type=self.mime_type) + if isinstance(raw_value, ResourceResult): + return raw_value + + # ResourceResult.__init__ handles all normalization + return ResourceResult(raw_value) async def _read( self, uri: str, params: dict[str, Any] - ) -> ResourceContent | mcp.types.CreateTaskResult: + ) -> ResourceResult | mcp.types.CreateTaskResult: """Server entry point that handles task routing. This allows ANY ResourceTemplate subclass to support background execution @@ -198,9 +207,7 @@ async def _read( # Call resource.read() not resource._read() to avoid task routing on ephemeral resource resource = await self.create_resource(uri, params) result = await resource.read() - if isinstance(result, ResourceContent): - return result - return resource.convert_result(result) + return self.convert_result(result) async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: """Create a resource from the template with the given parameters. @@ -289,7 +296,7 @@ class FunctionResourceTemplate(ResourceTemplate): async def _read( self, uri: str, params: dict[str, Any] - ) -> ResourceContent | mcp.types.CreateTaskResult: + ) -> ResourceResult | mcp.types.CreateTaskResult: """Optimized server entry point that skips ephemeral resource creation. For FunctionResourceTemplate, we can call read() directly instead of @@ -315,7 +322,7 @@ async def _read( async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: """Create a resource from the template with the given parameters.""" - async def resource_read_fn() -> str | bytes: + async def resource_read_fn() -> str | bytes | ResourceResult: # Call function and check if result is a coroutine result = await self.read(arguments=params) return result @@ -330,7 +337,7 @@ async def resource_read_fn() -> str | bytes: task=self.task_config, ) - async def read(self, arguments: dict[str, Any]) -> str | bytes: + async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult: """Read the resource content.""" # Type coercion for query parameters (which arrive as strings) kwargs = arguments.copy() diff --git a/src/fastmcp/resources/types.py b/src/fastmcp/resources/types.py index 0d2e647dd4..5f7fbd5119 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, ResourceContent +from fastmcp.resources.resource import Resource, ResourceContent, ResourceResult from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -23,9 +23,11 @@ class TextResource(Resource): text: str = Field(description="Text content of the resource") - async def read(self) -> ResourceContent: + async def read(self) -> ResourceResult: """Read the text content.""" - return ResourceContent(content=self.text, mime_type=self.mime_type) + return ResourceResult( + contents=[ResourceContent(content=self.text, mime_type=self.mime_type)] + ) class BinaryResource(Resource): @@ -33,9 +35,11 @@ class BinaryResource(Resource): data: bytes = Field(description="Binary content of the resource") - async def read(self) -> ResourceContent: + async def read(self) -> ResourceResult: """Read the binary content.""" - return ResourceContent(content=self.data, mime_type=self.mime_type) + return ResourceResult( + contents=[ResourceContent(content=self.data, mime_type=self.mime_type)] + ) class FileResource(Resource): @@ -76,14 +80,16 @@ def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> boo return not mime_type.startswith("text/") @override - async def read(self) -> ResourceContent: + async def read(self) -> ResourceResult: """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 ResourceResult( + contents=[ResourceContent(content=content, mime_type=self.mime_type)] + ) except Exception as e: raise ResourceError(f"Error reading file {self.path}") from e @@ -97,12 +103,16 @@ class HttpResource(Resource): ) @override - async def read(self) -> ResourceContent: + async def read(self) -> ResourceResult: """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 ResourceResult( + contents=[ + ResourceContent(content=response.text, mime_type=self.mime_type) + ] + ) class DirectoryResource(Resource): @@ -147,7 +157,7 @@ async def list_files(self) -> list[Path]: raise ResourceError(f"Error listing directory {self.path}") from e @override - async def read(self) -> ResourceContent: + async def read(self) -> ResourceResult: """Read the directory listing.""" try: files: list[Path] = await self.list_files() @@ -155,6 +165,8 @@ async def read(self) -> ResourceContent: 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 ResourceResult( + contents=[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 ba708aeef9..5271b523ad 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -38,7 +38,7 @@ from typing_extensions import TypeVar from fastmcp import settings -from fastmcp.resources.resource import ResourceContent +from fastmcp.resources.resource import ResourceResult from fastmcp.server.elicitation import ( AcceptedElicitation, CancelledElicitation, @@ -310,14 +310,14 @@ async def get_prompt( ) return result.to_mcp_prompt_result() - async def read_resource(self, uri: str | AnyUrl) -> list[ResourceContent]: + async def read_resource(self, uri: str | AnyUrl) -> ResourceResult: """Read a resource by URI. Args: uri: Resource URI to read Returns: - List of ResourceContent objects + ResourceResult with contents """ result = await self.fastmcp.read_resource(str(uri)) if isinstance(result, mcp.types.CreateTaskResult): diff --git a/src/fastmcp/server/low_level.py b/src/fastmcp/server/low_level.py index d75b88abf3..e5d812d5c5 100644 --- a/src/fastmcp/server/low_level.py +++ b/src/fastmcp/server/low_level.py @@ -26,8 +26,6 @@ from fastmcp.prompts import Prompt from fastmcp.prompts.prompt import PromptResult -from fastmcp.resources import Resource -from fastmcp.resources.types import ResourceContent from fastmcp.server.dependencies import _docket_fn_key, _task_metadata from fastmcp.utilities.logging import get_logger @@ -212,20 +210,19 @@ def read_resource( [ Callable[ [AnyUrl], - Awaitable[list[ResourceContent] | mcp.types.CreateTaskResult], + Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult], ] ], Callable[ [AnyUrl], - Awaitable[list[ResourceContent] | mcp.types.CreateTaskResult], + Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult], ], ]: """ Decorator for registering a read_resource handler with CreateTaskResult support. The MCP SDK's read_resource decorator does not support returning CreateTaskResult - for background task execution. This decorator provides that support by wrapping the - handler with task metadata extraction, contextvar management, and MCP format conversion. + for background task execution. This decorator wraps the result in ServerResult. This decorator can be removed once the MCP SDK adds native CreateTaskResult support for resources. @@ -234,43 +231,17 @@ def read_resource( def decorator( func: Callable[ [AnyUrl], - Awaitable[list[ResourceContent] | mcp.types.CreateTaskResult], + Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult], ], ) -> Callable[ [AnyUrl], - Awaitable[list[ResourceContent] | mcp.types.CreateTaskResult], + Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult], ]: async def handler( req: mcp.types.ReadResourceRequest, ) -> mcp.types.ServerResult: - uri = req.params.uri - - # Extract task metadata from request context - task_meta_dict: dict[str, Any] | None = None - try: - ctx = self.request_context - if ctx.experimental.is_task: - task_meta = ctx.experimental.task_metadata - task_meta_dict = task_meta.model_dump(exclude_none=True) - except (AttributeError, LookupError): - pass - - # Set contextvars - task_token = _task_metadata.set(task_meta_dict) - key_token = _docket_fn_key.set(Resource.make_key(str(uri))) - try: - result = await func(uri) - - if isinstance(result, mcp.types.CreateTaskResult): - return mcp.types.ServerResult(result) - - contents = [item.to_mcp_resource_contents(uri) for item in result] - return mcp.types.ServerResult( - mcp.types.ReadResourceResult(contents=contents) - ) - finally: - _task_metadata.reset(task_token) - _docket_fn_key.reset(key_token) + result = await func(req.params.uri) + return mcp.types.ServerResult(result) self.request_handlers[mcp.types.ReadResourceRequest] = handler return func diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index 828c95f0bf..a068e0e216 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -18,7 +18,7 @@ from typing_extensions import NotRequired, Self, override from fastmcp.prompts.prompt import Prompt, PromptResult -from fastmcp.resources.resource import Resource, ResourceContent +from fastmcp.resources.resource import Resource, ResourceContent, ResourceResult from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.tools.tool import Tool, ToolResult from fastmcp.utilities.logging import get_logger @@ -34,35 +34,45 @@ GLOBAL_KEY = "__global__" -class CachableReadResourceContents(BaseModel): +class CachableResourceContent(BaseModel): """A wrapper for ResourceContent that can be cached.""" content: str | bytes mime_type: str | None = None meta: dict[str, Any] | None = None + +class CachableResourceResult(BaseModel): + """A wrapper for ResourceResult that can be cached.""" + + contents: list[CachableResourceContent] + meta: dict[str, Any] | None = None + def get_size(self) -> int: return len(self.model_dump_json()) @classmethod - def get_sizes(cls, values: Sequence[Self]) -> int: - 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, value: ResourceResult) -> Self: + return cls( + contents=[ + CachableResourceContent( + content=item.content, mime_type=item.mime_type, meta=item.meta + ) + for item in value.contents + ], + meta=value.meta, + ) - @classmethod - def unwrap(cls, values: Sequence[Self]) -> list[ResourceContent]: - return [ - ResourceContent( - content=item.content, mime_type=item.mime_type, meta=item.meta - ) - for item in values - ] + def unwrap(self) -> ResourceResult: + return ResourceResult( + contents=[ + ResourceContent( + content=item.content, mime_type=item.mime_type, meta=item.meta + ) + for item in self.contents + ], + meta=self.meta, + ) class CachableToolResult(BaseModel): @@ -214,12 +224,12 @@ def __init__( default_collection="prompts/list", ) - self._read_resource_cache: PydanticAdapter[ - list[CachableReadResourceContents] - ] = PydanticAdapter( - key_value=self._stats, - pydantic_model=list[CachableReadResourceContents], # type: ignore[arg-type] - default_collection="resources/read", + self._read_resource_cache: PydanticAdapter[CachableResourceResult] = ( + PydanticAdapter( + key_value=self._stats, + pydantic_model=CachableResourceResult, # type: ignore[arg-type] + default_collection="resources/read", + ) ) self._get_prompt_cache: PydanticAdapter[PromptResult] = PydanticAdapter( @@ -386,23 +396,21 @@ async def on_call_tool( async def on_read_resource( self, context: MiddlewareContext[mcp.types.ReadResourceRequestParams], - call_next: CallNext[ - mcp.types.ReadResourceRequestParams, Sequence[ResourceContent] - ], - ) -> Sequence[ResourceContent]: + call_next: CallNext[mcp.types.ReadResourceRequestParams, ResourceResult], + ) -> ResourceResult: """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: return await call_next(context=context) cache_key: str = str(context.message.uri) - cached_value: list[CachableReadResourceContents] | None + cached_value: CachableResourceResult | None if cached_value := await self._read_resource_cache.get(key=cache_key): - return CachableReadResourceContents.unwrap(values=cached_value) + return cached_value.unwrap() - value: Sequence[ResourceContent] = await call_next(context=context) - cached_value = CachableReadResourceContents.wrap(values=value) + value: ResourceResult = await call_next(context=context) + cached_value = CachableResourceResult.wrap(value) await self._read_resource_cache.put( key=cache_key, @@ -410,7 +418,7 @@ async def on_read_resource( ttl=self._read_resource_settings.get("ttl", ONE_HOUR_IN_SECONDS), ) - return CachableReadResourceContents.unwrap(values=cached_value) + return cached_value.unwrap() @override async def on_get_prompt( diff --git a/src/fastmcp/server/middleware/middleware.py b/src/fastmcp/server/middleware/middleware.py index ca3d875cc1..8022ee9dde 100644 --- a/src/fastmcp/server/middleware/middleware.py +++ b/src/fastmcp/server/middleware/middleware.py @@ -18,7 +18,7 @@ from typing_extensions import TypeVar from fastmcp.prompts.prompt import Prompt, PromptResult -from fastmcp.resources.resource import Resource, ResourceContent +from fastmcp.resources.resource import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate from fastmcp.tools.tool import Tool, ToolResult @@ -163,8 +163,8 @@ async def on_call_tool( async def on_read_resource( self, context: MiddlewareContext[mt.ReadResourceRequestParams], - call_next: CallNext[mt.ReadResourceRequestParams, Sequence[ResourceContent]], - ) -> Sequence[ResourceContent]: + call_next: CallNext[mt.ReadResourceRequestParams, ResourceResult], + ) -> ResourceResult: 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 3c49272a8c..8f3ef0b43a 100644 --- a/src/fastmcp/server/middleware/tool_injection.py +++ b/src/fastmcp/server/middleware/tool_injection.py @@ -9,7 +9,7 @@ from pydantic import AnyUrl from typing_extensions import override -from fastmcp.resources.resource import ResourceContent +from fastmcp.resources.resource import ResourceResult 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[ResourceContent]: +) -> ResourceResult: """Read a resource available on the server.""" return await context.read_resource(uri=uri) diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py index cf6b3f6f39..ce0a7ad7b3 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -19,7 +19,7 @@ from mcp.types import AnyUrl from fastmcp.prompts.prompt import Prompt, PromptResult -from fastmcp.resources.resource import Resource, ResourceContent +from fastmcp.resources.resource import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate from fastmcp.server.providers.base import Provider from fastmcp.tools.tool import Tool, ToolResult @@ -140,16 +140,11 @@ def wrap(cls, server: Any, resource: Resource) -> FastMCPProviderResource: task_config=resource.task_config, ) - async def _read(self) -> ResourceContent | mcp.types.CreateTaskResult: - """Skip task routing - delegate to read() which calls child middleware. + async def _read(self) -> ResourceResult | mcp.types.CreateTaskResult: + """Skip task routing - delegate to child server's read_resource(). The actual underlying resource will check _task_metadata contextvar and submit to Docket if appropriate. This wrapper just passes through. - """ - return await self.read() - - async def read(self) -> ResourceContent | mcp.types.CreateTaskResult: # type: ignore[override] - """Delegate to child server's read_resource(). Note: The _docket_fn_key contextvar is intentionally NOT updated here. The parent set it to the full namespaced key (e.g., data://c/gc/value) @@ -157,10 +152,7 @@ async def read(self) -> ResourceContent | mcp.types.CreateTaskResult: # type: i layers pass this through unchanged so the eventual resource._read() uses the correct Docket lookup key. """ - result = await self._server.read_resource(self._original_uri) - if isinstance(result, mcp.types.CreateTaskResult): - return result - return result[0] + return await self._server.read_resource(self._original_uri) class FastMCPProviderPrompt(Prompt): @@ -279,7 +271,7 @@ async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: async def _read( self, uri: str, params: dict[str, Any] - ) -> ResourceContent | mcp.types.CreateTaskResult: + ) -> ResourceResult | mcp.types.CreateTaskResult: """Delegate to child server's read_resource(). Skips task routing at this layer - the child's template._read() will @@ -308,18 +300,15 @@ async def _read( if not existing_key or "{" not in existing_key: key_token = _docket_fn_key.set(self.key) try: - result = await self._server.read_resource(original_uri) - if isinstance(result, mcp.types.CreateTaskResult): - return result - return result[0] + return await self._server.read_resource(original_uri) finally: if key_token is not None: _docket_fn_key.reset(key_token) - async def read(self, arguments: dict[str, Any]) -> str | bytes: + async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult: """Read the resource content for background task execution. - Creates a resource from this template and reads its content. + Reads the resource via the wrapped server and returns the ResourceResult. This method is called by Docket during background task execution. """ # Expand the original template with arguments to get internal URI @@ -327,21 +316,12 @@ async def read(self, arguments: dict[str, Any]) -> str | bytes: self._original_uri_template or "", arguments ) - # Create and read the resource - resource = FastMCPProviderResource( - server=self._server, - original_uri=original_uri, - uri=AnyUrl(original_uri), - name=self.name, - description=self.description, - mime_type=self.mime_type, - ) - result = await resource.read() + # Read from the wrapped server + result = await self._server.read_resource(original_uri) + if isinstance(result, mcp.types.CreateTaskResult): + raise RuntimeError("Unexpected CreateTaskResult during Docket execution") - # Return raw content (str or bytes) - if hasattr(result, "content"): - return result.content # type: ignore[return-value] - return result # type: ignore[return-value] + return result def register_with_docket(self, docket: Docket) -> None: """No-op: the child's actual template is registered via get_tasks().""" diff --git a/src/fastmcp/server/providers/openapi/components.py b/src/fastmcp/server/providers/openapi/components.py index 5114a31d58..96fa54946f 100644 --- a/src/fastmcp/server/providers/openapi/components.py +++ b/src/fastmcp/server/providers/openapi/components.py @@ -11,7 +11,12 @@ from mcp.types import ToolAnnotations from pydantic.networks import AnyUrl -from fastmcp.resources import Resource, ResourceContent, ResourceTemplate +from fastmcp.resources import ( + Resource, + ResourceContent, + ResourceResult, + ResourceTemplate, +) from fastmcp.server.dependencies import get_http_headers from fastmcp.server.tasks.config import TaskConfig from fastmcp.tools.tool import Tool, ToolResult @@ -185,7 +190,7 @@ def __init__( def __repr__(self) -> str: return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})" - async def read(self) -> ResourceContent: + async def read(self) -> ResourceResult: """Fetch the resource data by making an HTTP request.""" try: path = self._route.path @@ -229,14 +234,26 @@ async def read(self) -> ResourceContent: if "application/json" in content_type: result = response.json() - return ResourceContent( - content=json.dumps(result), mime_type="application/json" + return ResourceResult( + contents=[ + ResourceContent( + content=json.dumps(result), mime_type="application/json" + ) + ] ) elif any(ct in content_type for ct in ["text/", "application/xml"]): - return ResourceContent(content=response.text, mime_type=self.mime_type) + return ResourceResult( + contents=[ + ResourceContent(content=response.text, mime_type=self.mime_type) + ] + ) else: - return ResourceContent( - content=response.content, mime_type=self.mime_type + return ResourceResult( + contents=[ + ResourceContent( + content=response.content, mime_type=self.mime_type + ) + ] ) except httpx.HTTPStatusError as e: diff --git a/src/fastmcp/server/providers/proxy.py b/src/fastmcp/server/providers/proxy.py index 061840bbf9..15efddafa9 100644 --- a/src/fastmcp/server/providers/proxy.py +++ b/src/fastmcp/server/providers/proxy.py @@ -36,7 +36,7 @@ from fastmcp.prompts import Prompt, PromptResult from fastmcp.prompts.prompt import PromptArgument from fastmcp.resources import Resource, ResourceTemplate -from fastmcp.resources.resource import ResourceContent +from fastmcp.resources.resource import ResourceContent, ResourceResult from fastmcp.server.context import Context from fastmcp.server.dependencies import get_context from fastmcp.server.providers.base import Provider @@ -154,14 +154,14 @@ class ProxyResource(Resource): """A Resource that represents and reads a resource from a remote server.""" task_config: TaskConfig = TaskConfig(mode="forbidden") - _cached_content: ResourceContent | None = None + _cached_content: ResourceResult | None = None _backend_uri: str | None = None def __init__( self, client_factory: ClientFactoryT, *, - _cached_content: ResourceContent | None = None, + _cached_content: ResourceResult | None = None, **kwargs, ): super().__init__(**kwargs) @@ -205,7 +205,7 @@ def from_mcp_resource( task_config=TaskConfig(mode="forbidden"), ) - async def read(self) -> ResourceContent: + async def read(self) -> ResourceResult: """Read the resource content from the remote server.""" if self._cached_content is not None: return self._cached_content @@ -219,16 +219,24 @@ async def read(self) -> ResourceContent: f"Remote server returned empty content for {backend_uri}" ) if isinstance(result[0], TextResourceContents): - return ResourceContent( - content=result[0].text, - mime_type=result[0].mimeType, - meta=result[0].meta, + return ResourceResult( + contents=[ + ResourceContent( + content=result[0].text, + mime_type=result[0].mimeType, + meta=result[0].meta, + ) + ] ) elif isinstance(result[0], BlobResourceContents): - return ResourceContent( - content=base64.b64decode(result[0].blob), - mime_type=result[0].mimeType, - meta=result[0].meta, + return ResourceResult( + contents=[ + 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])}") @@ -303,16 +311,24 @@ async def create_resource( 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, + cached_content = ResourceResult( + contents=[ + ResourceContent( + content=result[0].text, + mime_type=result[0].mimeType, + meta=result[0].meta, + ) + ] ) elif isinstance(result[0], BlobResourceContents): - cached_content = ResourceContent( - content=base64.b64decode(result[0].blob), - mime_type=result[0].mimeType, - meta=result[0].meta, + cached_content = ResourceResult( + contents=[ + 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])}") diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index c156dbf0c9..f48061fcd8 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -66,7 +66,7 @@ from fastmcp.mcp_config import MCPConfig from fastmcp.prompts import Prompt from fastmcp.prompts.prompt import FunctionPrompt, PromptResult -from fastmcp.resources.resource import Resource, ResourceContent +from fastmcp.resources.resource import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate from fastmcp.server.auth import AuthProvider from fastmcp.server.event_store import EventStore @@ -1197,7 +1197,7 @@ async def read_resource( uri: str, *, run_middleware: bool = True, - ) -> list[ResourceContent] | mcp.types.CreateTaskResult: + ) -> ResourceResult | mcp.types.CreateTaskResult: """Read a resource by URI. This is the public API for reading resources. By default, middleware is applied. @@ -1209,7 +1209,7 @@ async def read_resource( Set to False when called from middleware to avoid re-applying. Returns: - List of ResourceContent objects. + ResourceResult with contents. May return CreateTaskResult if called in MCP context with task metadata. Raises: @@ -1235,7 +1235,7 @@ async def read_resource( ) if isinstance(result, mcp.types.CreateTaskResult): return result - return list(result) + return result # Core logic: find and read resource # First pass: try concrete resources from all providers @@ -1246,9 +1246,7 @@ async def read_resource( result = await resource._read() if isinstance(result, mcp.types.CreateTaskResult): return result - if result.mime_type is None: - result.mime_type = resource.mime_type - return [result] + return result except (FastMCPError, McpError): logger.exception(f"Error reading resource {uri!r}") raise @@ -1272,7 +1270,7 @@ async def read_resource( result = await template._read(uri, params) if isinstance(result, mcp.types.CreateTaskResult): return result - return [result] + return result except (FastMCPError, McpError): logger.exception(f"Error reading resource {uri!r}") raise @@ -1540,16 +1538,45 @@ async def _call_tool_mcp( async def _read_resource_mcp( self, uri: AnyUrl | str - ) -> list[ResourceContent] | mcp.types.CreateTaskResult: + ) -> mcp.types.ReadResourceResult | mcp.types.CreateTaskResult: """Handle MCP 'readResource' requests. - The LowLevelServer.read_resource() decorator handles task metadata, - contextvars, and MCP conversion. + Sets task metadata contextvar and calls read_resource(). The resource's + _read() method handles the backgrounding decision. + + Args: + uri: The resource URI + + Returns: + ReadResourceResult or CreateTaskResult for background execution """ + from fastmcp.server.dependencies import _docket_fn_key, _task_metadata + logger.debug(f"[{self.name}] Handler called: read_resource %s", uri) try: - return await self.read_resource(str(uri)) + # Extract SEP-1686 task metadata from request context + task_meta_dict: dict[str, Any] | None = None + try: + ctx = self._mcp_server.request_context + if ctx.experimental.is_task: + task_meta = ctx.experimental.task_metadata + task_meta_dict = task_meta.model_dump(exclude_none=True) + except (AttributeError, LookupError): + pass + + # Set contextvars so resource._read() can access them + task_token = _task_metadata.set(task_meta_dict) + key_token = _docket_fn_key.set(Resource.make_key(str(uri))) + try: + result = await self.read_resource(str(uri)) + + if isinstance(result, mcp.types.CreateTaskResult): + return result + return result.to_mcp_result(uri) + finally: + _task_metadata.reset(task_token) + _docket_fn_key.reset(key_token) except DisabledError as e: raise NotFoundError(f"Unknown resource: {str(uri)!r}") from e except NotFoundError: diff --git a/src/fastmcp/server/tasks/requests.py b/src/fastmcp/server/tasks/requests.py index 434958953e..fa513df067 100644 --- a/src/fastmcp/server/tasks/requests.py +++ b/src/fastmcp/server/tasks/requests.py @@ -323,22 +323,16 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any: return mcp_result elif isinstance(component, ResourceTemplate): - resource_content = component.convert_result(raw_value) - mcp_content = resource_content.to_mcp_resource_contents( - component.uri_template - ) - return mcp.types.ReadResourceResult( - contents=[mcp_content], - _meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field - ) + fastmcp_result = component.convert_result(raw_value) + mcp_result = fastmcp_result.to_mcp_result(component.uri_template) + mcp_result._meta = related_task_meta # type: ignore[attr-defined] + return mcp_result elif isinstance(component, Resource): - resource_content = component.convert_result(raw_value) - mcp_content = resource_content.to_mcp_resource_contents(str(component.uri)) - return mcp.types.ReadResourceResult( - contents=[mcp_content], - _meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field - ) + fastmcp_result = component.convert_result(raw_value) + mcp_result = fastmcp_result.to_mcp_result(str(component.uri)) + mcp_result._meta = related_task_meta # type: ignore[attr-defined] + return mcp_result else: raise McpError( diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 0990443fea..bb1f53da59 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -50,15 +50,22 @@ async def sleep(seconds: float) -> str: await asyncio.sleep(seconds) return f"Slept for {seconds} seconds" - # Add a resource + # Add a resource (return JSON string for proper typing) @server.resource(uri="data://users") - async def get_users(): - return ["Alice", "Bob", "Charlie"] + async def get_users() -> str: + import json - # Add a resource template + return json.dumps(["Alice", "Bob", "Charlie"], separators=(",", ":")) + + # Add a resource template (return JSON string for proper typing) @server.resource(uri="data://user/{user_id}") - async def get_user(user_id: str): - return {"id": user_id, "name": f"User {user_id}", "active": True} + async def get_user(user_id: str) -> str: + import json + + return json.dumps( + {"id": user_id, "name": f"User {user_id}", "active": True}, + separators=(",", ":"), + ) # Add a prompt @server.prompt @@ -72,14 +79,16 @@ def welcome(name: str) -> str: @pytest.fixture def tagged_resources_server(): """Fixture that creates a FastMCP server with tagged resources and templates.""" + import json + server = FastMCP("TaggedResourcesServer") # Add a resource with tags @server.resource( uri="data://tagged", tags={"test", "metadata"}, description="A tagged resource" ) - async def get_tagged_data(): - return {"type": "tagged_data"} + async def get_tagged_data() -> str: + return json.dumps({"type": "tagged_data"}, separators=(",", ":")) # Add a resource template with tags @server.resource( @@ -87,8 +96,8 @@ async def get_tagged_data(): tags={"template", "parameterized"}, description="A tagged template", ) - async def get_template_data(id: str): - return {"id": id, "type": "template_data"} + async def get_template_data(id: str) -> str: + return json.dumps({"id": id, "type": "template_data"}, separators=(",", ":")) return server diff --git a/tests/client/test_sse.py b/tests/client/test_sse.py index cec868615a..b57a8b4e31 100644 --- a/tests/client/test_sse.py +++ b/tests/client/test_sse.py @@ -35,17 +35,23 @@ async def sleep(seconds: float) -> str: return f"Slept for {seconds} seconds" @server.resource(uri="data://users") - async def get_users(): - return ["Alice", "Bob", "Charlie"] + async def get_users() -> str: + import json + + return json.dumps(["Alice", "Bob", "Charlie"]) @server.resource(uri="data://user/{user_id}") - async def get_user(user_id: str): - return {"id": user_id, "name": f"User {user_id}", "active": True} + async def get_user(user_id: str) -> str: + import json + + return json.dumps({"id": user_id, "name": f"User {user_id}", "active": True}) @server.resource(uri="request://headers") - async def get_headers() -> dict[str, str]: + async def get_headers() -> str: + import json + request = get_http_request() - return dict(request.headers) + return json.dumps(dict(request.headers)) @server.prompt def welcome(name: str) -> str: diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index b4407e0c35..e87f9e2593 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -54,17 +54,23 @@ async def greet_with_progress(name: str, ctx: Context) -> str: return f"Hello, {name}!" @server.resource(uri="data://users") - async def get_users(): - return ["Alice", "Bob", "Charlie"] + async def get_users() -> str: + import json + + return json.dumps(["Alice", "Bob", "Charlie"]) @server.resource(uri="data://user/{user_id}") - async def get_user(user_id: str): - return {"id": user_id, "name": f"User {user_id}", "active": True} + async def get_user(user_id: str) -> str: + import json + + return json.dumps({"id": user_id, "name": f"User {user_id}", "active": True}) @server.resource(uri="request://headers") - async def get_headers() -> dict[str, str]: + async def get_headers() -> str: + import json + request = get_http_request() - return dict(request.headers) + return json.dumps(dict(request.headers)) @server.prompt def welcome(name: str) -> str: diff --git a/tests/deprecated/test_import_server.py b/tests/deprecated/test_import_server.py index 9d9c7887cd..ffbc094438 100644 --- a/tests/deprecated/test_import_server.py +++ b/tests/deprecated/test_import_server.py @@ -104,8 +104,8 @@ async def test_import_with_resources(): # Add a resource to the data app @data_app.resource(uri="data://users") - async def get_users(): - return ["user1", "user2"] + async def get_users() -> str: + return "user1, user2" # Import the data app await main_app.import_server(data_app, "data") @@ -123,8 +123,12 @@ async def test_import_with_resource_templates(): # Add a resource template to the user app @user_app.resource(uri="users://{user_id}/profile") - def get_user_profile(user_id: str) -> dict: - return {"id": user_id, "name": f"User {user_id}"} + def get_user_profile(user_id: str) -> str: + import json + + return json.dumps( + {"id": user_id, "name": f"User {user_id}"}, separators=(",", ":") + ) # Import the user app await main_app.import_server(user_app, "api") @@ -358,11 +362,15 @@ async def test_import_with_proxy_resources(): # Create a resource in the API app @api_app.resource(uri="config://settings") - def get_config(): - return { - "api_key": "12345", - "base_url": "https://api.example.com", - } + def get_config() -> str: + import json + + return json.dumps( + { + "api_key": "12345", + "base_url": "https://api.example.com", + } + ) proxy_app = FastMCP.as_proxy(api_app) await main_app.import_server(proxy_app, "api") @@ -389,8 +397,10 @@ async def test_import_with_proxy_resource_templates(): # Create a resource template in the API app @api_app.resource(uri="user://{name}/{email}") - def create_user(name: str, email: str): - return {"name": name, "email": email} + def create_user(name: str, email: str) -> str: + import json + + return json.dumps({"name": name, "email": email}) proxy_app = FastMCP.as_proxy(api_app) await main_app.import_server(proxy_app, "api") diff --git a/tests/resources/test_file_resources.py b/tests/resources/test_file_resources.py index 1f4a8bf2c1..71e95ea990 100644 --- a/tests/resources/test_file_resources.py +++ b/tests/resources/test_file_resources.py @@ -7,7 +7,7 @@ from fastmcp.exceptions import ResourceError from fastmcp.resources import FileResource -from fastmcp.resources.resource import ResourceContent +from fastmcp.resources.resource import ResourceResult @pytest.fixture @@ -63,9 +63,10 @@ async def test_read_text_file(self, temp_file: Path): path=temp_file, ) result = await resource.read() - assert isinstance(result, ResourceContent) - assert result.content == "test content" - assert result.mime_type == "text/plain" + assert isinstance(result, ResourceResult) + assert len(result.contents) == 1 + assert result.contents[0].content == "test content" + assert result.contents[0].mime_type == "text/plain" async def test_read_binary_file(self, temp_file: Path): """Test reading a file as binary.""" @@ -76,8 +77,9 @@ async def test_read_binary_file(self, temp_file: Path): is_binary=True, ) result = await resource.read() - assert isinstance(result, ResourceContent) - assert result.content == b"test content" + assert isinstance(result, ResourceResult) + assert len(result.contents) == 1 + assert result.contents[0].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 9316c56af4..c819b11b52 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, ResourceContent, ResourceResult class TestFunctionResource: @@ -36,10 +36,16 @@ def get_data() -> str: name="test", fn=get_data, ) + # read() returns raw value result = await resource.read() - assert isinstance(result, ResourceContent) - assert result.content == "Hello, world!" - assert result.mime_type == "text/plain" + assert result == "Hello, world!" + + # _read() converts to ResourceResult + result = await resource._read() + assert isinstance(result, ResourceResult) + assert len(result.contents) == 1 + assert result.contents[0].content == "Hello, world!" + assert result.contents[0].mime_type == "text/plain" async def test_read_binary(self): """Test reading binary data from a FunctionResource.""" @@ -52,12 +58,17 @@ def get_data() -> bytes: name="test", fn=get_data, ) + # read() returns raw value result = await resource.read() - assert isinstance(result, ResourceContent) - assert result.content == b"Hello, world!" + assert result == b"Hello, world!" - async def test_json_conversion(self): - """Test automatic JSON conversion of non-string results.""" + # _read() converts to ResourceResult + result = await resource._read() + assert isinstance(result, ResourceResult) + assert result.contents[0].content == b"Hello, world!" + + async def test_dict_return_raises_type_error(self): + """Returning dict from read() raises TypeError - use ResourceResult.""" def get_data() -> dict: return {"key": "value"} @@ -67,10 +78,13 @@ def get_data() -> dict: name="test", fn=get_data, ) + # read() returns raw value (no type checking at runtime) result = await resource.read() - assert isinstance(result, ResourceContent) - assert isinstance(result.content, str) - assert '"key":"value"' in result.content + assert result == {"key": "value"} + + # _read() raises TypeError - must return str, bytes, or ResourceResult + with pytest.raises(TypeError, match="must be str, bytes, or list"): + await resource._read() async def test_error_handling(self): """Test error handling in FunctionResource.""" @@ -86,8 +100,8 @@ def failing_func() -> str: with pytest.raises(ValueError, match="Test error"): await resource.read() - async def test_basemodel_conversion(self): - """Test handling of BaseModel types.""" + async def test_basemodel_return_raises_type_error(self): + """Returning BaseModel from read() raises TypeError - use ResourceResult.""" class MyModel(BaseModel): name: str @@ -97,12 +111,16 @@ class MyModel(BaseModel): name="test", fn=lambda: MyModel(name="test"), ) - result = await resource.read() - assert isinstance(result, ResourceContent) - assert result.content == '{"name":"test"}' + # read() returns raw value (no type checking at runtime) + raw_result = await resource.read() + assert isinstance(raw_result, MyModel) - async def test_custom_type_conversion(self): - """Test handling of custom types.""" + # _read() raises TypeError - must return str, bytes, or ResourceResult + with pytest.raises(TypeError, match="must be str, bytes, or list"): + await resource._read() + + async def test_custom_type_return_raises_type_error(self): + """Returning custom type from read() raises TypeError - use ResourceResult.""" class CustomData: def __str__(self) -> str: @@ -116,9 +134,13 @@ def get_data() -> CustomData: name="test", fn=get_data, ) - result = await resource.read() - assert isinstance(result, ResourceContent) - assert isinstance(result.content, str) + # read() returns raw value (no type checking at runtime) + raw_result = await resource.read() + assert isinstance(raw_result, CustomData) + + # _read() raises TypeError - must return str, bytes, or ResourceResult + with pytest.raises(TypeError, match="must be str, bytes, or list"): + await resource._read() async def test_async_read_text(self): """Test reading text from async FunctionResource.""" @@ -131,10 +153,15 @@ async def get_data() -> str: name="test", fn=get_data, ) + # read() returns raw value result = await resource.read() - assert isinstance(result, ResourceContent) - assert result.content == "Hello, world!" - assert result.mime_type == "text/plain" + assert result == "Hello, world!" + + # _read() converts to ResourceResult + result = await resource._read() + assert isinstance(result, ResourceResult) + assert result.contents[0].content == "Hello, world!" + assert result.contents[0].mime_type == "text/plain" async def test_resource_content_text(self): """Test returning ResourceContent with text content.""" @@ -178,21 +205,11 @@ def get_data() -> ResourceContent: 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 + """Test that ResourceContent auto-sets mime_type defaults.""" + content = ResourceContent(content="plain text") + assert content.content == "plain text" + assert content.mime_type == "text/plain" # Auto-set for string content + assert content.meta is None async def test_async_resource_content(self): """Test async function returning ResourceContent.""" diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 21979c01dd..b583276613 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -1,5 +1,4 @@ import functools -import json from urllib.parse import quote import pytest @@ -7,7 +6,7 @@ from fastmcp import Context, FastMCP from fastmcp.resources import ResourceTemplate -from fastmcp.resources.resource import FunctionResource, ResourceContent +from fastmcp.resources.resource import FunctionResource, ResourceResult from fastmcp.resources.template import match_uri_template @@ -168,8 +167,8 @@ def multi_required(param1: str, param2: int, optional: str = "default") -> dict: async def test_create_resource(self): """Test creating a resource from a template.""" - def my_func(key: str, value: int) -> dict: - return {"key": key, "value": value} + def my_func(key: str, value: int) -> str: + return f"key={key}, value={value}" template = ResourceTemplate.from_function( fn=my_func, @@ -183,11 +182,15 @@ def my_func(key: str, value: int) -> dict: ) assert isinstance(resource, FunctionResource) + # read() returns raw value from function result = await resource.read() - assert isinstance(result, ResourceContent) - assert isinstance(result.content, str) - data = json.loads(result.content) - assert data == {"key": "foo", "value": 123} + assert result == "key=foo, value=123" + + # _read() wraps in ResourceResult + resource_result = await resource._read() + assert isinstance(resource_result, ResourceResult) + assert len(resource_result.contents) == 1 + assert resource_result.contents[0].content == "key=foo, value=123" async def test_async_text_resource(self): """Test creating a text resource from async function.""" @@ -207,9 +210,9 @@ async def greet(name: str) -> str: ) assert isinstance(resource, FunctionResource) + # read() returns raw value result = await resource.read() - assert isinstance(result, ResourceContent) - assert result.content == "Hello, world!" + assert result == "Hello, world!" async def test_async_binary_resource(self): """Test creating a binary resource from async function.""" @@ -229,9 +232,9 @@ async def get_bytes(value: str) -> bytes: ) assert isinstance(resource, FunctionResource) + # read() returns raw bytes result = await resource.read() - assert isinstance(result, ResourceContent) - assert result.content == b"test" + assert result == b"test" async def test_basemodel_conversion(self): """Test handling of BaseModel types.""" @@ -255,10 +258,10 @@ def get_data(key: str, value: int) -> MyModel: ) assert isinstance(resource, FunctionResource) + # read() returns raw BaseModel result = await resource.read() - assert isinstance(result, ResourceContent) - assert isinstance(result.content, str) - data = json.loads(result.content) + assert isinstance(result, MyModel) + data = result.model_dump() assert data == {"key": "foo", "value": 123} async def test_custom_type_conversion(self): @@ -286,9 +289,10 @@ def get_data(value: str) -> CustomData: ) assert isinstance(resource, FunctionResource) + # read() returns raw CustomData object result = await resource.read() - assert isinstance(result, ResourceContent) - assert result.content == '"hello"' + assert isinstance(result, CustomData) + assert str(result) == "hello" async def test_wildcard_param_can_create_resource(self): """Test that wildcard parameters are valid.""" @@ -397,9 +401,9 @@ def __call__(self, x: str) -> str: ) assert isinstance(resource, FunctionResource) + # read() returns raw string from __call__ result = await resource.read() - assert isinstance(result, ResourceContent) - assert result.content == "X was foo" + assert result == "X was foo" class TestMatchUriTemplate: @@ -684,9 +688,9 @@ def resource_with_context(x: int, ctx: Context) -> str: ) assert isinstance(resource, FunctionResource) + # read() returns the raw value result = await resource.read() - assert isinstance(result, ResourceContent) - assert result.content == "42" + assert result == "42" async def test_context_optional(self): """Test that context is optional when creating resources.""" @@ -711,9 +715,9 @@ def resource_with_context(x: int, ctx: Context | None = None) -> str: ) assert isinstance(resource, FunctionResource) + # read() returns the raw value result = await resource.read() - assert isinstance(result, ResourceContent) - assert result.content == "42" + assert result == "42" async def test_context_with_functools_wraps_decorator(self): """Regression test for #2524: decorated templates with Context should work.""" @@ -741,10 +745,9 @@ async def decorated_template(ctx: Context, item_id: int) -> str: async with context: resource = await template.create_resource("test://42", {"item_id": 42}) + # read() returns the raw value result = await resource.read() - assert isinstance(result, ResourceContent) - assert isinstance(result.content, str) - assert result.content == "item: 42" + assert result == "item: 42" class TestQueryParameterExtraction: @@ -816,11 +819,11 @@ def get_page(resource: str, page: int = 1) -> dict: {"resource": "docs", "page": "5"}, ) + # read() returns raw dict 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 + assert isinstance(result, dict) + assert result["page"] == 5 + assert result["type"] == "int" async def test_bool_coercion(self): """Test boolean type coercion for query parameters.""" @@ -839,10 +842,10 @@ def get_config(name: str, enabled: bool = False) -> dict: "config://feature?enabled=true", {"name": "feature", "enabled": "true"}, ) + # read() returns raw dict result = await resource.read() - assert isinstance(result, ResourceContent) - assert isinstance(result.content, str) - assert '"enabled":true' in result.content + assert isinstance(result, dict) + assert result["enabled"] is True # Test false value resource = await template.create_resource( @@ -850,9 +853,8 @@ def get_config(name: str, enabled: bool = False) -> dict: {"name": "feature", "enabled": "false"}, ) result = await resource.read() - assert isinstance(result, ResourceContent) - assert isinstance(result.content, str) - assert '"enabled":false' in result.content + assert isinstance(result, dict) + assert result["enabled"] is False async def test_float_coercion(self): """Test float type coercion for query parameters.""" @@ -875,11 +877,11 @@ def get_metrics(service: str, threshold: float = 0.5) -> dict: {"service": "api", "threshold": "0.95"}, ) + # read() returns raw dict 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 + assert isinstance(result, dict) + assert result["threshold"] == 0.95 + assert result["type"] == "float" class TestQueryParameterValidation: @@ -937,11 +939,11 @@ def get_data(id: str, format: str = "json", verbose: bool = False) -> dict: {"id": "123"}, ) + # read() returns raw dict 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 + assert isinstance(result, dict) + assert result["format"] == "json" + assert result["verbose"] is False async def test_partial_query_params(self): """Test providing only some query parameters.""" @@ -963,12 +965,12 @@ def get_data( {"id": "123", "limit": "20"}, ) + # read() returns raw dict 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 + assert isinstance(result, dict) + assert result["format"] == "json" # default + assert result["limit"] == 20 # provided + assert result["offset"] == 0 # default class TestQueryParameterWithWildcards: @@ -1000,9 +1002,9 @@ def get_file(path: str, encoding: str = "utf-8", lines: int = 100) -> dict: {"path": "src/test/data.txt", "lines": "50"}, ) + # read() returns raw dict 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 + assert isinstance(result, dict) + assert result["path"] == "src/test/data.txt" + assert result["encoding"] == "utf-8" # default + assert result["lines"] == 50 # provided diff --git a/tests/resources/test_resources.py b/tests/resources/test_resources.py index 3e30be1d8a..8b9906cc43 100644 --- a/tests/resources/test_resources.py +++ b/tests/resources/test_resources.py @@ -1,7 +1,9 @@ +import mcp.types import pytest -from pydantic import AnyUrl +from pydantic import AnyUrl, BaseModel -from fastmcp.resources import Resource +from fastmcp import Client, FastMCP +from fastmcp.resources import Resource, ResourceContent, ResourceResult from fastmcp.resources.resource import FunctionResource @@ -114,3 +116,242 @@ def resource_func() -> str: # MCP resource includes fastmcp meta, so check that our meta is included assert mcp_resource.meta is not None assert meta_data.items() <= mcp_resource.meta.items() + + +class TestResourceContent: + """Test ResourceContent creation and conversion.""" + + def test_string_content(self): + """String input creates text content with text/plain mime type.""" + content = ResourceContent("hello world") + assert content.content == "hello world" + assert content.mime_type == "text/plain" + assert content.meta is None + + def test_bytes_content(self): + """Bytes input creates binary content with octet-stream mime type.""" + content = ResourceContent(b"\x00\x01\x02") + assert content.content == b"\x00\x01\x02" + assert content.mime_type == "application/octet-stream" + assert content.meta is None + + def test_dict_serialized_to_json(self): + """Dict input is JSON-serialized with application/json mime type.""" + content = ResourceContent({"key": "value", "count": 42}) + assert content.content == '{"key":"value","count":42}' + assert content.mime_type == "application/json" + + def test_list_serialized_to_json(self): + """List input is JSON-serialized.""" + content = ResourceContent([1, 2, 3]) + assert content.content == "[1,2,3]" + assert content.mime_type == "application/json" + + def test_pydantic_model_serialized_to_json(self): + """Pydantic model is JSON-serialized.""" + + class Item(BaseModel): + name: str + price: float + + content = ResourceContent(Item(name="Widget", price=9.99)) + assert content.content == '{"name":"Widget","price":9.99}' + assert content.mime_type == "application/json" + + def test_custom_mime_type(self): + """Custom mime type overrides default.""" + content = ResourceContent("test", mime_type="text/html") + assert content.mime_type == "text/html" + + def test_with_meta(self): + """Meta is passed through to content.""" + content = ResourceContent("test", meta={"version": "1.0"}) + assert content.meta == {"version": "1.0"} + + def test_to_mcp_text_contents(self): + """Text content converts to TextResourceContents.""" + content = ResourceContent( + content="hello", mime_type="text/plain", meta={"k": "v"} + ) + mcp_content = content.to_mcp_resource_contents("resource://test") + assert isinstance(mcp_content, mcp.types.TextResourceContents) + assert mcp_content.text == "hello" + assert mcp_content.mimeType == "text/plain" + assert str(mcp_content.uri) == "resource://test" + assert mcp_content.meta == {"k": "v"} + + def test_to_mcp_blob_contents(self): + """Binary content converts to BlobResourceContents with base64.""" + content = ResourceContent( + content=b"\x00\x01\x02", mime_type="application/octet-stream" + ) + mcp_content = content.to_mcp_resource_contents("resource://binary") + assert isinstance(mcp_content, mcp.types.BlobResourceContents) + assert mcp_content.blob == "AAEC" # base64 of \x00\x01\x02 + assert mcp_content.mimeType == "application/octet-stream" + + +class TestResourceResult: + """Test ResourceResult initialization and conversion.""" + + def test_init_from_string(self): + """String input is normalized to list[ResourceContent].""" + result = ResourceResult("hello world") + assert len(result.contents) == 1 + assert result.contents[0].content == "hello world" + assert result.contents[0].mime_type == "text/plain" + + def test_init_from_bytes(self): + """Bytes input is normalized to list[ResourceContent].""" + result = ResourceResult(b"\xff\xfe") + assert len(result.contents) == 1 + assert result.contents[0].content == b"\xff\xfe" + assert result.contents[0].mime_type == "application/octet-stream" + + def test_init_from_dict_raises_type_error(self): + """Dict input raises TypeError - must use ResourceContent for serialization.""" + with pytest.raises(TypeError, match="must be str, bytes, or list"): + ResourceResult({"page": 1, "total": 100}) # type: ignore[arg-type] + + def test_init_from_single_resource_content_raises_type_error(self): + """Single ResourceContent raises TypeError - must be in a list.""" + content = ResourceContent(content="test", mime_type="text/html") + with pytest.raises(TypeError, match="must be str, bytes, or list"): + ResourceResult(content) # type: ignore[arg-type] + + def test_init_from_list_of_resource_content(self): + """List of ResourceContent is used directly.""" + contents = [ + ResourceContent(content="one", mime_type="text/plain"), + ResourceContent(content="two", mime_type="text/plain"), + ] + result = ResourceResult(contents) + assert len(result.contents) == 2 + assert result.contents[0].content == "one" + assert result.contents[1].content == "two" + + def test_init_from_mixed_list_raises_type_error(self): + """Mixed list items raise TypeError - all items must be ResourceContent.""" + with pytest.raises(TypeError, match=r"contents\[0\] must be ResourceContent"): + ResourceResult(["text", b"bytes", {"key": "value"}]) # type: ignore[arg-type] + + def test_init_preserves_meta(self): + """Meta is preserved on ResourceResult.""" + result = ResourceResult("test", meta={"version": "2.0"}) + assert result.meta == {"version": "2.0"} + + def test_to_mcp_result(self): + """Converts to MCP ReadResourceResult with proper structure.""" + result = ResourceResult( + contents=[ResourceContent(content="hello", mime_type="text/plain")], + meta={"source": "test"}, + ) + mcp_result = result.to_mcp_result("resource://test") + assert isinstance(mcp_result, mcp.types.ReadResourceResult) + assert len(mcp_result.contents) == 1 + assert isinstance(mcp_result.contents[0], mcp.types.TextResourceContents) + assert mcp_result.contents[0].text == "hello" + assert str(mcp_result.contents[0].uri) == "resource://test" + assert mcp_result.meta == {"source": "test"} + + def test_to_mcp_result_multiple_contents(self): + """Multiple contents all get same URI.""" + result = ResourceResult( + [ + ResourceContent("one"), + ResourceContent("two"), + ResourceContent("three"), + ] + ) + mcp_result = result.to_mcp_result("resource://multi") + assert len(mcp_result.contents) == 3 + for item in mcp_result.contents: + assert str(item.uri) == "resource://multi" + + +class TestResourceConvertResult: + """Test Resource.convert_result() method.""" + + def test_passthrough_resource_result(self): + """ResourceResult input is returned unchanged.""" + + def fn() -> str: + return "test" + + resource = FunctionResource(uri=AnyUrl("test://test"), name="test", fn=fn) + original = ResourceResult("test", meta={"original": True}) + result = resource.convert_result(original) + assert result is original + + def test_converts_raw_value(self): + """Raw values are converted to ResourceResult.""" + + def fn() -> str: + return "test" + + resource = FunctionResource(uri=AnyUrl("test://test"), name="test", fn=fn) + result = resource.convert_result("hello") + assert isinstance(result, ResourceResult) + assert len(result.contents) == 1 + assert result.contents[0].content == "hello" + + async def test_read_returns_resource_result(self): + """_read() returns ResourceResult after conversion.""" + + def fn() -> str: + return "hello world" + + resource = FunctionResource(uri=AnyUrl("test://test"), name="test", fn=fn) + result = await resource._read() + assert isinstance(result, ResourceResult) + assert len(result.contents) == 1 + assert result.contents[0].content == "hello world" + + +class TestResourceMetaPropagation: + """Test that meta is properly propagated through the full MCP flow.""" + + async def test_resource_result_meta_received_by_client(self): + """Meta set on ResourceResult is received by MCP client.""" + mcp = FastMCP() + + @mcp.resource("test://with-meta") + def resource_with_meta() -> ResourceResult: + return ResourceResult("hello", meta={"version": "2.0", "source": "test"}) + + async with Client(mcp) as client: + result = await client.read_resource_mcp("test://with-meta") + assert result.meta == {"version": "2.0", "source": "test"} + + async def test_resource_content_meta_received_by_client(self): + """Meta set on ResourceContent is received by MCP client.""" + mcp = FastMCP() + + @mcp.resource("test://content-meta") + def resource_with_content_meta() -> ResourceResult: + return ResourceResult( + [ResourceContent(content="data", meta={"item_version": "1.0"})] + ) + + async with Client(mcp) as client: + result = await client.read_resource_mcp("test://content-meta") + assert len(result.contents) == 1 + assert result.contents[0].meta == {"item_version": "1.0"} + + async def test_both_result_and_content_meta(self): + """Both result-level and content-level meta are propagated.""" + mcp = FastMCP() + + @mcp.resource("test://both-meta") + def resource_both_meta() -> ResourceResult: + return ResourceResult( + contents=[ + ResourceContent(content="item", meta={"item_key": "item_val"}) + ], + meta={"result_key": "result_val"}, + ) + + async with Client(mcp) as client: + result = await client.read_resource_mcp("test://both-meta") + assert result.meta == {"result_key": "result_val"} + assert result.contents[0].meta == {"item_key": "item_val"} diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py index a4b9659a61..e379f5e83a 100644 --- a/tests/server/http/test_http_dependencies.py +++ b/tests/server/http/test_http_dependencies.py @@ -22,10 +22,11 @@ def get_headers_tool() -> dict[str, str]: return dict(request.headers) @server.resource(uri="request://headers") - async def get_headers_resource() -> dict[str, str]: - request = get_http_request() + async def get_headers_resource() -> str: + import json - return dict(request.headers) + request = get_http_request() + return json.dumps(dict(request.headers)) # Add a prompt @server.prompt diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index a79a661a1d..b9ccbecc6b 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -132,14 +132,14 @@ def crazy(self, a: CrazyModel) -> CrazyModel: def how_to_calculate(self, a: int, b: int) -> str: return f"To calculate {a} + {b}, you need to add {a} and {b} together." - def get_add_calls(self) -> int: - return self.add_calls + def get_add_calls(self) -> str: + return str(self.add_calls) - def get_multiply_calls(self) -> int: - return self.multiply_calls + def get_multiply_calls(self) -> str: + return str(self.multiply_calls) - def get_crazy_calls(self) -> int: - return self.crazy_calls + def get_crazy_calls(self) -> str: + return str(self.crazy_calls) async def update_tool_list(self, context: Context): import mcp.types diff --git a/tests/server/middleware/test_middleware.py b/tests/server/middleware/test_middleware.py index 8a6fbf0c91..5ebae8451e 100644 --- a/tests/server/middleware/test_middleware.py +++ b/tests/server/middleware/test_middleware.py @@ -7,6 +7,7 @@ from fastmcp import Client, FastMCP from fastmcp.exceptions import ToolError +from fastmcp.resources.resource import ResourceResult from fastmcp.server.context import Context from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.tools.tool import ToolResult @@ -513,8 +514,9 @@ def test_resource() -> str: result = await server.read_resource("resource://test") - assert len(result) == 1 # type: ignore[arg-type] - assert result[0].content == "test content" # type: ignore[union-attr,index] + assert isinstance(result, ResourceResult) + assert len(result.contents) == 1 + assert result.contents[0].content == "test content" assert recording.assert_called(hook="on_read_resource", times=1) async def test_read_resource_with_run_middleware_false(self): @@ -530,8 +532,9 @@ def test_resource() -> str: result = await server.read_resource("resource://test", run_middleware=False) - assert len(result) == 1 # type: ignore[arg-type] - assert result[0].content == "test content" # type: ignore[union-attr,index] + assert isinstance(result, ResourceResult) + assert len(result.contents) == 1 + assert result.contents[0].content == "test content" # Middleware should not have been called assert len(recording.calls) == 0 @@ -548,8 +551,9 @@ def get_item(item_id: int) -> str: result = await server.read_resource("resource://items/42", run_middleware=False) - assert len(result) == 1 # type: ignore[arg-type] - assert result[0].content == "item 42" # type: ignore[union-attr,index] + assert isinstance(result, ResourceResult) + assert len(result.contents) == 1 + assert result.contents[0].content == "item 42" assert len(recording.calls) == 0 async def test_render_prompt_with_run_middleware_true(self): diff --git a/tests/server/middleware/test_tool_injection.py b/tests/server/middleware/test_tool_injection.py index b2eb29fad4..f8bd866d95 100644 --- a/tests/server/middleware/test_tool_injection.py +++ b/tests/server/middleware/test_tool_injection.py @@ -494,14 +494,15 @@ async def test_read_resource_tool_works(self, server_with_resources: FastMCP): [ TextContent( type="text", - text='[{"content":"debug=true","mime_type":"text/plain","meta":null}]', + text='{"contents":[{"content":"debug=true","mime_type":"text/plain","meta":null}],"meta":null}', ) ] ) assert result.structured_content == snapshot( { - "result": [ + "contents": [ {"content": "debug=true", "mime_type": "text/plain", "meta": None} - ] + ], + "meta": None, } ) diff --git a/tests/server/providers/test_fastmcp_provider.py b/tests/server/providers/test_fastmcp_provider.py index 49b3a7a05a..3dc530b8bd 100644 --- a/tests/server/providers/test_fastmcp_provider.py +++ b/tests/server/providers/test_fastmcp_provider.py @@ -1,13 +1,11 @@ """Tests for FastMCPProvider.""" -from collections.abc import Sequence - import mcp.types as mt from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.prompts.prompt import PromptResult -from fastmcp.resources.resource import ResourceContent +from fastmcp.resources.resource import ResourceResult from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.server.providers import FastMCPProvider from fastmcp.tools.tool import ToolResult @@ -43,8 +41,8 @@ def __init__(self, name: str, calls: list[str]): async def on_read_resource( self, context: MiddlewareContext[mt.ReadResourceRequestParams], - call_next: CallNext[mt.ReadResourceRequestParams, Sequence[ResourceContent]], - ) -> Sequence[ResourceContent]: + call_next: CallNext[mt.ReadResourceRequestParams, ResourceResult], + ) -> ResourceResult: self._calls.append(f"{self._name}:before") result = await call_next(context) self._calls.append(f"{self._name}:after") diff --git a/tests/server/providers/test_local_provider_resources.py b/tests/server/providers/test_local_provider_resources.py index cda21fc4d7..7b26c00fca 100644 --- a/tests/server/providers/test_local_provider_resources.py +++ b/tests/server/providers/test_local_provider_resources.py @@ -14,7 +14,12 @@ from pydantic import AnyUrl from fastmcp import Client, Context, FastMCP -from fastmcp.resources import Resource, ResourceContent, ResourceTemplate +from fastmcp.resources import ( + Resource, + ResourceContent, + ResourceResult, + ResourceTemplate, +) class TestResourceContext: @@ -127,8 +132,8 @@ async def test_template_with_varkwargs(self): mcp = FastMCP() @mcp.resource("test://{x}/{y}/{z}") - def func(**kwargs: int) -> int: - return sum(kwargs.values()) + def func(**kwargs: int) -> str: + return str(sum(int(v) for v in kwargs.values())) async with Client(mcp) as client: result = await client.read_resource(AnyUrl("test://1/2/3")) @@ -140,8 +145,8 @@ async def test_template_with_default_params(self): mcp = FastMCP() @mcp.resource("math://add/{x}") - def add(x: int, y: int = 10) -> int: - return x + y + def add(x: int, y: int = 10) -> str: + return str(int(x) + y) templates = await mcp.get_resource_templates() assert len(templates) == 1 @@ -504,11 +509,15 @@ async def test_resource_content_with_meta_in_response(self): 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"}, + def get_widget() -> ResourceResult: + return ResourceResult( + [ + ResourceContent( + content="content", + mime_type="text/html", + meta={"csp": "script-src 'self'", "version": "1.0"}, + ) + ] ) async with Client(mcp) as client: @@ -525,10 +534,14 @@ async def test_resource_content_binary_with_meta(self): mcp = FastMCP() @mcp.resource("resource://binary") - def get_binary() -> ResourceContent: - return ResourceContent( - content=b"\x00\x01\x02", - meta={"encoding": "raw"}, + def get_binary() -> ResourceResult: + return ResourceResult( + [ + ResourceContent( + content=b"\x00\x01\x02", + meta={"encoding": "raw"}, + ) + ] ) async with Client(mcp) as client: @@ -543,8 +556,8 @@ async def test_resource_content_without_meta(self): mcp = FastMCP() @mcp.resource("resource://plain") - def get_plain() -> ResourceContent: - return ResourceContent(content="plain content") + def get_plain() -> ResourceResult: + return ResourceResult([ResourceContent(content="plain content")]) async with Client(mcp) as client: result = await client.read_resource("resource://plain") diff --git a/tests/server/providers/test_local_provider_tools.py b/tests/server/providers/test_local_provider_tools.py index edb6e7e974..b7527224f3 100644 --- a/tests/server/providers/test_local_provider_tools.py +++ b/tests/server/providers/test_local_provider_tools.py @@ -1094,10 +1094,9 @@ def test_resource() -> str: @mcp.tool async def tool_with_resource(ctx: Context) -> str: - r_iter = await ctx.read_resource("test://data") - r_list = list(r_iter) - assert len(r_list) == 1 - r = r_list[0] + result = await ctx.read_resource("test://data") + assert len(result.contents) == 1 + r = result.contents[0] return f"Read resource: {r.content} with mime type {r.mime_type}" async with Client(mcp) as client: diff --git a/tests/server/proxy/test_proxy_server.py b/tests/server/proxy/test_proxy_server.py index 5bf9573957..da0e074e37 100644 --- a/tests/server/proxy/test_proxy_server.py +++ b/tests/server/proxy/test_proxy_server.py @@ -67,8 +67,10 @@ def wave() -> str: return "👋" @server.resource(uri="data://users") - async def get_users() -> list[dict[str, Any]]: - return USERS + async def get_users() -> str: + import json + + return json.dumps(USERS, separators=(",", ":")) @server.resource( uri="data://user/{user_id}", @@ -76,8 +78,11 @@ async def get_users() -> list[dict[str, Any]]: title="User Template", icons=[Icon(src="https://example.com/user-icon.png")], ) - async def get_user(user_id: str) -> dict[str, Any] | None: - return next((user for user in USERS if user["id"] == user_id), None) + async def get_user(user_id: str) -> str: + import json + + user = next((user for user in USERS if user["id"] == user_id), None) + return json.dumps(user, separators=(",", ":")) if user else "null" # --- Prompts --- @@ -302,8 +307,11 @@ async def test_read_resource_same_as_original(self, fastmcp_server, proxy_server async def test_read_json_resource(self, proxy_server: FastMCPProxy): async with Client(proxy_server) as client: result = await client.read_resource("data://users") + assert len(result) == 1 assert isinstance(result[0], TextResourceContents) - assert json.loads(result[0].text) == USERS + # The resource returns all users serialized as JSON + users = json.loads(result[0].text) + assert users == USERS async def test_read_resource_returns_none_if_not_found(self, proxy_server): with pytest.raises( @@ -388,13 +396,15 @@ async def test_proxy_can_overwrite_proxied_resource_template(self, proxy_server) """ @proxy_server.resource(uri="data://user/{user_id}", name="overwritten_get_user") - def overwritten_get_user(user_id: str) -> dict[str, Any]: - return { - "id": user_id, - "name": "Overwritten User", - "active": True, - "extra": "data", - } + def overwritten_get_user(user_id: str) -> str: + return json.dumps( + { + "id": user_id, + "name": "Overwritten User", + "active": True, + "extra": "data", + } + ) async with Client(proxy_server) as client: result = await client.read_resource("data://user/1") diff --git a/tests/server/tasks/test_task_mount.py b/tests/server/tasks/test_task_mount.py index 70f6497323..395e5dc13a 100644 --- a/tests/server/tasks/test_task_mount.py +++ b/tests/server/tasks/test_task_mount.py @@ -6,7 +6,6 @@ """ import asyncio -from collections.abc import Sequence import mcp.types as mt import pytest @@ -15,7 +14,7 @@ from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.prompts.prompt import PromptResult -from fastmcp.resources.resource import ResourceContent +from fastmcp.resources.resource import ResourceResult from fastmcp.server.dependencies import CurrentDocket, CurrentFastMCP from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.server.tasks import TaskConfig @@ -591,8 +590,8 @@ def __init__(self, name: str, calls: list[str]): async def on_read_resource( self, context: MiddlewareContext[mt.ReadResourceRequestParams], - call_next: CallNext[mt.ReadResourceRequestParams, Sequence[ResourceContent]], - ) -> Sequence[ResourceContent]: + call_next: CallNext[mt.ReadResourceRequestParams, ResourceResult], + ) -> ResourceResult: self._calls.append(f"{self._name}:before") result = await call_next(context) self._calls.append(f"{self._name}:after") diff --git a/tests/server/tasks/test_task_return_types.py b/tests/server/tasks/test_task_return_types.py index f3519e0c7a..cbceac4b49 100644 --- a/tests/server/tasks/test_task_return_types.py +++ b/tests/server/tasks/test_task_return_types.py @@ -191,9 +191,11 @@ async def simple_text() -> str: return "Simple text resource" @mcp.resource("data://json", task=True) - async def json_data() -> dict[str, Any]: + async def json_data() -> str: """Return JSON-like data.""" - return {"key": "value", "count": 123} + import json + + return json.dumps({"key": "value", "count": 123}) return mcp diff --git a/tests/server/test_file_server.py b/tests/server/test_file_server.py index 592803d782..4b3eb97791 100644 --- a/tests/server/test_file_server.py +++ b/tests/server/test_file_server.py @@ -1,9 +1,10 @@ -import json from pathlib import Path +import mcp.types as mcp_types import pytest from fastmcp import FastMCP +from fastmcp.resources import ResourceContent, ResourceResult @pytest.fixture() @@ -29,9 +30,10 @@ def mcp() -> FastMCP: @pytest.fixture(autouse=True) def resources(mcp: FastMCP, test_dir: Path) -> FastMCP: @mcp.resource("dir://test_dir") - def list_test_dir() -> list[str]: + def list_test_dir() -> ResourceResult: """List the files in the test directory""" - return [str(f) for f in test_dir.iterdir()] + files = [str(f) for f in test_dir.iterdir()] + return ResourceResult([ResourceContent(f) for f in files]) @mcp.resource("file://test_dir/example.py") def read_example_py() -> str: @@ -86,13 +88,16 @@ async def test_list_resources(mcp: FastMCP): async def test_read_resource_dir(mcp: FastMCP): - res_iter = await mcp._read_resource_mcp("dir://test_dir") - res_list = list(res_iter) - assert len(res_list) == 1 - res = res_list[0] - assert res.mime_type == "text/plain" - - files = json.loads(res.content) + res_result = await mcp._read_resource_mcp("dir://test_dir") + assert isinstance(res_result, mcp_types.ReadResourceResult) + # ResourceResult splits lists into multiple contents (one per file path) + assert len(res_result.contents) == 3 + # Extract file paths from each content + files = [ + item.text + for item in res_result.contents + if isinstance(item, mcp_types.TextResourceContents) + ] assert sorted([Path(f).name for f in files]) == [ "config.json", @@ -102,11 +107,12 @@ async def test_read_resource_dir(mcp: FastMCP): async def test_read_resource_file(mcp: FastMCP): - res_iter = await mcp._read_resource_mcp("file://test_dir/example.py") - res_list = list(res_iter) - assert len(res_list) == 1 - res = res_list[0] - assert res.content == "print('hello world')" + res_result = await mcp._read_resource_mcp("file://test_dir/example.py") + assert isinstance(res_result, mcp_types.ReadResourceResult) + assert len(res_result.contents) == 1 + res = res_result.contents[0] + assert isinstance(res, mcp_types.TextResourceContents) + assert res.text == "print('hello world')" async def test_delete_file(mcp: FastMCP, test_dir: Path): @@ -120,8 +126,9 @@ async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path): await mcp._call_tool_mcp( "delete_file", arguments=dict(path=str(test_dir / "example.py")) ) - res_iter = await mcp._read_resource_mcp("file://test_dir/example.py") - res_list = list(res_iter) - assert len(res_list) == 1 - res = res_list[0] - assert res.content == "File not found" + res_result = await mcp._read_resource_mcp("file://test_dir/example.py") + assert isinstance(res_result, mcp_types.ReadResourceResult) + assert len(res_result.contents) == 1 + res = res_result.contents[0] + assert isinstance(res, mcp_types.TextResourceContents) + assert res.text == "File not found" diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index 5fbbf9d197..eec59187af 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -642,8 +642,8 @@ async def test_mount_with_resources(self): data_app = FastMCP("DataApp") @data_app.resource(uri="data://users") - async def get_users(): - return ["user1", "user2"] + async def get_users() -> str: + return "user1, user2" # Mount the data app main_app.mount(data_app, "data") @@ -655,8 +655,9 @@ async def get_users(): # Check that resource can be accessed async with Client(main_app) as client: result = await client.read_resource("data://data/users") + assert len(result) == 1 assert isinstance(result[0], TextResourceContents) - assert json.loads(result[0].text) == ["user1", "user2"] + assert result[0].text == "user1, user2" async def test_mount_with_resource_templates(self): """Test mounting a server with resource templates.""" @@ -664,8 +665,8 @@ async def test_mount_with_resource_templates(self): user_app = FastMCP("UserApp") @user_app.resource(uri="users://{user_id}/profile") - def get_user_profile(user_id: str) -> dict: - return {"id": user_id, "name": f"User {user_id}"} + def get_user_profile(user_id: str) -> str: + return json.dumps({"id": user_id, "name": f"User {user_id}"}) # Mount the user app main_app.mount(user_app, "api") @@ -692,8 +693,8 @@ async def test_adding_resource_after_mounting(self): # Add a resource after mounting @data_app.resource(uri="data://config") - def get_config(): - return {"version": "1.0"} + def get_config() -> str: + return json.dumps({"version": "1.0"}) # Resource should be accessible through main app resources = await main_app.get_resources() @@ -816,8 +817,8 @@ async def test_proxy_server_with_resources(self): original_server = FastMCP("OriginalServer") @original_server.resource(uri="config://settings") - def get_config(): - return {"api_key": "12345"} + def get_config() -> str: + return json.dumps({"api_key": "12345"}) # Create proxy server proxy_server = FastMCP.as_proxy(FastMCPTransport(original_server)) diff --git a/v3-notes/resource-internal-types.md b/v3-notes/resource-internal-types.md new file mode 100644 index 0000000000..24cd6328ca --- /dev/null +++ b/v3-notes/resource-internal-types.md @@ -0,0 +1,196 @@ +# Resource Internal Types - Strict Typing for Type Safety + +**Version:** 3.0.0 +**Impact:** Breaking change for resources returning dict/list + +## Summary + +ResourceResult now enforces strict typing to catch errors at development time (via type checker) rather than at runtime (when a client reads a resource). + +## What Changed + +### Before (v2.x) +```python +@mcp.resource("data://config") +def get_config() -> dict: # Auto-serialized to JSON + return {"key": "value"} + +@mcp.resource("data://items") +def get_items() -> list: # Each item auto-wrapped + return ["item1", "item2"] + +ResourceResult({"key": "value"}) # Dict auto-converted +ResourceResult(["a", "b"]) # List split into items +``` + +### After (v3.0) +```python +@mcp.resource("data://config") +def get_config() -> str: # Explicit JSON serialization + import json + return json.dumps({"key": "value"}) + +@mcp.resource("data://items") +def get_items() -> ResourceResult: # Explicit multi-item response + return ResourceResult([ + ResourceContent("item1"), + ResourceContent("item2"), + ]) + +ResourceResult([ResourceContent(...)]) # Explicit list wrapping +# Dict/list raises TypeError +``` + +## Type Constraints + +### Resource.read() Return Type +```python +str | bytes | ResourceResult +``` + +**Valid:** +- `return "text content"` +- `return b"binary data"` +- `return ResourceResult([ResourceContent(...)])` + +**Invalid (now raises TypeError):** +- `return {"key": "value"}` → Use `json.dumps()` instead +- `return ["item1", "item2"]` → Use `ResourceResult([ResourceContent(...)])` +- `return ResourceContent(...)` → Use `ResourceResult([ResourceContent(...)])` + +### ResourceResult Type Signature +```python +ResourceResult( + contents: str | bytes | list[ResourceContent], + meta: dict[str, Any] | None = None +) +``` + +**Valid:** +- `ResourceResult("plain text")` +- `ResourceResult(b"binary")` +- `ResourceResult([ResourceContent(...), ResourceContent(...)])` + +**Invalid (now raises TypeError):** +- `ResourceResult({"key": "value"})` → Dict not supported +- `ResourceResult(["a", "b"])` → Bare list not supported (must be list[ResourceContent]) +- `ResourceResult(resource_content_obj)` → Single item must be in list + +### ResourceContent Type Signature +```python +ResourceContent( + content: Any, # Auto-serializes non-str/bytes to JSON + mime_type: str | None = None, + meta: dict[str, Any] | None = None +) +``` + +**Auto-Serialization in ResourceContent.__init__:** +- `str` → passes through (mime_type defaults to "text/plain") +- `bytes` → passes through (mime_type defaults to "application/octet-stream") +- `dict` → JSON-serialized string (mime_type defaults to "application/json") +- `list` → JSON-serialized string (mime_type defaults to "application/json") +- `BaseModel` → JSON-serialized string (mime_type defaults to "application/json") + +## Why This Change? + +The old auto-conversion behavior was convenient but hid errors: + +```python +# Old behavior - silent failure +return ["item1", "item2"] # Client sees 2 items OR JSON array? +# Ambiguous! Users would discover issues only when client reads resource + +# New behavior - caught at dev time +return ["item1", "item2"] # Type checker error immediately +# Must explicitly write: +return json.dumps(["item1", "item2"]) # Clear intent +# OR: +return ResourceResult([ResourceContent("item1"), ResourceContent("item2")]) +``` + +Type checkers now catch return type mismatches during development rather than at runtime. + +## Migration Guide + +### Returning JSON Data +**Before:** +```python +def get_config() -> dict: + return {"key": "value", "nested": {"a": 1}} +``` + +**After:** +```python +import json + +def get_config() -> str: + return json.dumps({"key": "value", "nested": {"a": 1}}) +``` + +### Returning Multiple Items +**Before:** +```python +def get_items() -> list: + return ["user1", "user2", "user3"] +``` + +**After (Option 1: Single JSON array):** +```python +import json + +def get_items() -> str: + return json.dumps(["user1", "user2", "user3"]) +``` + +**After (Option 2: Multiple content items):** +```python +from fastmcp.resources import ResourceContent, ResourceResult + +def get_items() -> ResourceResult: + return ResourceResult([ + ResourceContent("user1"), + ResourceContent("user2"), + ResourceContent("user3"), + ]) +``` + +### Returning Structured Data with Custom MIME Types +**Before:** +```python +def get_html() -> dict: + return {"html": "
content
"} +``` + +**After:** +```python +from fastmcp.resources import ResourceContent, ResourceResult + +def get_html() -> ResourceResult: + return ResourceResult([ + ResourceContent( + content="
content
", + mime_type="text/html" + ) + ]) +``` + +## Type Checking + +Your type checker will now catch these errors: + +```python +@mcp.resource("data://test") +def bad_resource() -> dict: # ← Type error: should be str | bytes | ResourceResult + return {"key": "value"} +``` + +This is intentional. The type system enforces correct typing at development time. + +## Backward Compatibility + +**This is a breaking change.** Code that returns dict or list from resources will: +1. **Pass type checking**: If you ignore type warnings +2. **Fail at runtime**: Raises `TypeError` when client reads the resource + +Migrate to explicit JSON serialization or ResourceResult.