From 648684d2bb3185caf1c0fb96bdce8e1bfd2098ba Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 25 Dec 2025 21:21:55 -0500 Subject: [PATCH] Introduce ResourceResult as canonical resource return type (#2734) --- docs/servers/resources.mdx | 117 +++++---- src/fastmcp/resources/__init__.py | 3 +- src/fastmcp/resources/resource.py | 241 +++++++++++------ src/fastmcp/resources/template.py | 33 ++- src/fastmcp/resources/types.py | 34 ++- src/fastmcp/server/context.py | 6 +- src/fastmcp/server/low_level.py | 43 +-- src/fastmcp/server/middleware/caching.py | 76 +++--- src/fastmcp/server/middleware/middleware.py | 6 +- .../server/middleware/tool_injection.py | 4 +- .../server/providers/fastmcp_provider.py | 46 +--- .../server/providers/openapi/components.py | 31 ++- src/fastmcp/server/providers/proxy.py | 56 ++-- src/fastmcp/server/server.py | 51 +++- src/fastmcp/server/tasks/requests.py | 22 +- tests/client/test_client.py | 29 ++- tests/client/test_sse.py | 18 +- tests/client/test_streamable_http.py | 18 +- tests/deprecated/test_import_server.py | 32 ++- tests/resources/test_file_resources.py | 14 +- tests/resources/test_function_resources.py | 95 ++++--- tests/resources/test_resource_template.py | 110 ++++---- tests/resources/test_resources.py | 245 +++++++++++++++++- tests/server/http/test_http_dependencies.py | 7 +- tests/server/middleware/test_caching.py | 12 +- tests/server/middleware/test_middleware.py | 16 +- .../server/middleware/test_tool_injection.py | 7 +- .../server/providers/test_fastmcp_provider.py | 8 +- .../test_local_provider_resources.py | 45 ++-- .../providers/test_local_provider_tools.py | 7 +- tests/server/proxy/test_proxy_server.py | 34 ++- tests/server/tasks/test_task_mount.py | 7 +- tests/server/tasks/test_task_return_types.py | 6 +- tests/server/test_file_server.py | 47 ++-- tests/server/test_mount.py | 19 +- v3-notes/resource-internal-types.md | 196 ++++++++++++++ 36 files changed, 1188 insertions(+), 553 deletions(-) create mode 100644 v3-notes/resource-internal-types.md diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx index ea9d16f6d..35b909693 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. -#### ResourceContent + +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. + - +#### 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 +from fastmcp.resources import ResourceResult, ResourceContent -mcp = FastMCP(name="WidgetServer") +mcp = FastMCP() -@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'"} +@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 663210232..5048e95ca 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 @@ __all__ = [ "HttpResource", "Resource", "ResourceContent", + "ResourceResult", "ResourceTemplate", "TextResource", ] diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index bce843b0a..c51108857 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -4,7 +4,6 @@ from __future__ import annotations import base64 import inspect -import warnings from collections.abc import Callable from typing import TYPE_CHECKING, Annotated, Any, ClassVar @@ -27,7 +26,6 @@ from pydantic import ( ) 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 @@ from fastmcp.utilities.types import ( 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 @@ class ResourceContent(pydantic.BaseModel): ) +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 @@ class Resource(FastMCPComponent): raise ValueError("Either name or uri must be provided") return self - async def read(self) -> str | bytes | ResourceContent: + async def read( + self, + ) -> str | bytes | 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. - 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 - 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 @@ class Resource(FastMCPComponent): 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 @@ class FunctionResource(Resource): task_config=task_config, ) - async def read(self) -> str | bytes | ResourceContent: - """Read the resource by calling the wrapped function. - - Returns: - str | bytes | ResourceContent: The resource content. If the user's - function returns str, bytes, dict, etc., it will be wrapped - in ResourceContent. Nested Resource reads may return raw types. - """ + async def read( + self, + ) -> str | bytes | 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 @@ class FunctionResource(Resource): 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 00b8d2b01..3f73d512e 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -21,7 +21,7 @@ from pydantic import ( 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 @@ class ResourceTemplate(FastMCPComponent): """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 @@ class ResourceTemplate(FastMCPComponent): # 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 @@ class FunctionResourceTemplate(ResourceTemplate): 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 @@ class FunctionResourceTemplate(ResourceTemplate): 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 0d2e647dd..5f7fbd511 100644 --- a/src/fastmcp/resources/types.py +++ b/src/fastmcp/resources/types.py @@ -12,7 +12,7 @@ from pydantic import Field, ValidationInfo from typing_extensions import override from fastmcp.exceptions import ResourceError -from fastmcp.resources.resource import Resource, ResourceContent +from fastmcp.resources.resource import Resource, 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 @@ class FileResource(Resource): 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 @@ class DirectoryResource(Resource): 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 @@ class DirectoryResource(Resource): 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 ba708aeef..5271b523a 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -38,7 +38,7 @@ from starlette.requests import Request 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 @@ class Context: ) 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 d75b88abf..e5d812d5c 100644 --- a/src/fastmcp/server/low_level.py +++ b/src/fastmcp/server/low_level.py @@ -26,8 +26,6 @@ from pydantic import AnyUrl 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 @@ class LowLevelServer(_Server[LifespanResultT, RequestT]): [ 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 @@ class LowLevelServer(_Server[LifespanResultT, RequestT]): 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 828c95f0b..a068e0e21 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -18,7 +18,7 @@ from pydantic import BaseModel, Field 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 @@ ONE_MB_IN_BYTES = 1024 * 1024 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) + 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 wrap(cls, values: Sequence[ResourceContent]) -> list[Self]: - return [ - cls(content=item.content, mime_type=item.mime_type, meta=item.meta) - for item in values - ] - - @classmethod - def unwrap(cls, values: Sequence[Self]) -> list[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 @@ class ResponseCachingMiddleware(Middleware): 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 @@ class ResponseCachingMiddleware(Middleware): 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 @@ class ResponseCachingMiddleware(Middleware): 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 ca3d875cc..8022ee9dd 100644 --- a/src/fastmcp/server/middleware/middleware.py +++ b/src/fastmcp/server/middleware/middleware.py @@ -18,7 +18,7 @@ import mcp.types as mt 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 @@ class Middleware: async def on_read_resource( self, context: MiddlewareContext[mt.ReadResourceRequestParams], - call_next: CallNext[mt.ReadResourceRequestParams, Sequence[ResourceContent]], - ) -> Sequence[ResourceContent]: + call_next: CallNext[mt.ReadResourceRequestParams, 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 3c49272a8..8f3ef0b43 100644 --- a/src/fastmcp/server/middleware/tool_injection.py +++ b/src/fastmcp/server/middleware/tool_injection.py @@ -9,7 +9,7 @@ from mcp.types import Prompt 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 @@ list_resources_tool = Tool.from_function( async def read_resource( context: Context, uri: Annotated[AnyUrl | str, "The URI of the resource to read."], -) -> list[ResourceContent]: +) -> 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 cf6b3f6f3..ce0a7ad7b 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -19,7 +19,7 @@ import mcp.types 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 @@ class FastMCPProviderResource(Resource): 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 @@ class FastMCPProviderResource(Resource): 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 @@ class FastMCPProviderResourceTemplate(ResourceTemplate): 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 @@ class FastMCPProviderResourceTemplate(ResourceTemplate): 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 @@ class FastMCPProviderResourceTemplate(ResourceTemplate): 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 5114a31d5..96fa54946 100644 --- a/src/fastmcp/server/providers/openapi/components.py +++ b/src/fastmcp/server/providers/openapi/components.py @@ -11,7 +11,12 @@ import httpx 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 @@ class OpenAPIResource(Resource): 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 @@ class OpenAPIResource(Resource): 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 061840bbf..15efddafa 100644 --- a/src/fastmcp/server/providers/proxy.py +++ b/src/fastmcp/server/providers/proxy.py @@ -36,7 +36,7 @@ from fastmcp.mcp_config import MCPConfig 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 @@ class ProxyResource(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 @@ class ProxyResource(Resource): 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 @@ class ProxyTemplate(ResourceTemplate): 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 c156dbf0c..f48061fcd 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -66,7 +66,7 @@ from fastmcp.exceptions import ( 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 @@ class FastMCP(Generic[LifespanResultT]): 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 @@ class FastMCP(Generic[LifespanResultT]): 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 @@ class FastMCP(Generic[LifespanResultT]): ) 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 @@ class FastMCP(Generic[LifespanResultT]): 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 @@ class FastMCP(Generic[LifespanResultT]): 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 @@ class FastMCP(Generic[LifespanResultT]): 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 434958953..fa513df06 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 0990443fe..bb1f53da5 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -50,15 +50,22 @@ def fastmcp_server(): 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 fastmcp_server(): @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 @@ def tagged_resources_server(): 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 cec868615..b57a8b4e3 100644 --- a/tests/client/test_sse.py +++ b/tests/client/test_sse.py @@ -35,17 +35,23 @@ def create_test_server() -> FastMCP: 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 b4407e0c3..e87f9e259 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -54,17 +54,23 @@ def create_test_server() -> FastMCP: 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 9d9c7887c..ffbc09443 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 1f4a8bf2c..71e95ea99 100644 --- a/tests/resources/test_file_resources.py +++ b/tests/resources/test_file_resources.py @@ -7,7 +7,7 @@ from pydantic import FileUrl 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 @@ class TestFileResource: 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 @@ class TestFileResource: 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 9316c56af..c819b11b5 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 @@ class TestFunctionResource: 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 @@ class TestFunctionResource: 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 @@ class TestFunctionResource: 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 @@ class TestFunctionResource: 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 TestFunctionResource: 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 @@ class TestFunctionResource: 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 @@ class TestFunctionResource: 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 @@ class TestFunctionResource: 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 21979c01d..b58327661 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 pydantic import BaseModel from fastmcp import Context, FastMCP from fastmcp.resources import ResourceTemplate -from fastmcp.resources.resource import FunctionResource, ResourceContent +from fastmcp.resources.resource import FunctionResource, ResourceResult from fastmcp.resources.template import match_uri_template @@ -168,8 +167,8 @@ class TestResourceTemplate: 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 @@ class TestResourceTemplate: ) 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 @@ class TestResourceTemplate: ) 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 @@ class TestResourceTemplate: ) 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 @@ class TestResourceTemplate: ) 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 @@ class TestResourceTemplate: ) 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 @@ class TestResourceTemplate: ) 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 @@ class TestContextHandling: ) 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 @@ class TestContextHandling: ) 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 @@ class TestContextHandling: 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 @@ class TestQueryParameterTypeCoercion: {"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 @@ class TestQueryParameterTypeCoercion: "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 @@ class TestQueryParameterTypeCoercion: {"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 @@ class TestQueryParameterTypeCoercion: {"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 @@ class TestQueryParameterWithDefaults: {"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 @@ class TestQueryParameterWithDefaults: {"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 @@ class TestQueryParameterWithWildcards: {"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 3e30be1d8..8b9906cc4 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 @@ class TestResourceValidation: # 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 a4b9659a6..e379f5e83 100644 --- a/tests/server/http/test_http_dependencies.py +++ b/tests/server/http/test_http_dependencies.py @@ -22,10 +22,11 @@ def fastmcp_server(): 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 a79a661a1..b9ccbecc6 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -132,14 +132,14 @@ class TrackingCalculator: 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 8a6fbf0c9..5ebae8451 100644 --- a/tests/server/middleware/test_middleware.py +++ b/tests/server/middleware/test_middleware.py @@ -7,6 +7,7 @@ import pytest 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 @@ class TestApplyMiddlewareParameter: 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 @@ class TestApplyMiddlewareParameter: 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 @@ class TestApplyMiddlewareParameter: 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 b2eb29fad..f8bd866d9 100644 --- a/tests/server/middleware/test_tool_injection.py +++ b/tests/server/middleware/test_tool_injection.py @@ -494,14 +494,15 @@ class TestResourceToolMiddleware: [ 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 49b3a7a05..3dc530b8b 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 @@ class ResourceTracingMiddleware(Middleware): async def on_read_resource( self, context: MiddlewareContext[mt.ReadResourceRequestParams], - call_next: CallNext[mt.ReadResourceRequestParams, Sequence[ResourceContent]], - ) -> Sequence[ResourceContent]: + call_next: CallNext[mt.ReadResourceRequestParams, 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 cda21fc4d..7b26c00fc 100644 --- a/tests/server/providers/test_local_provider_resources.py +++ b/tests/server/providers/test_local_provider_resources.py @@ -14,7 +14,12 @@ from mcp.types import BlobResourceContents, TextResourceContents 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 @@ class TestResourceTemplates: 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 @@ class TestResourceTemplates: 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 @@ class TestResourceDecorator: 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 @@ class TestResourceDecorator: 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 @@ class TestResourceDecorator: 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 edb6e7e97..b7527224f 100644 --- a/tests/server/providers/test_local_provider_tools.py +++ b/tests/server/providers/test_local_provider_tools.py @@ -1094,10 +1094,9 @@ class TestToolContextInjection: @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 5bf957395..da0e074e3 100644 --- a/tests/server/proxy/test_proxy_server.py +++ b/tests/server/proxy/test_proxy_server.py @@ -67,8 +67,10 @@ def fastmcp_server(): 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 @@ def fastmcp_server(): 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 @@ class TestResources: 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 @@ class TestResourceTemplates: """ @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 70f649732..395e5dc13 100644 --- a/tests/server/tasks/test_task_mount.py +++ b/tests/server/tasks/test_task_mount.py @@ -6,7 +6,6 @@ on mounted child servers through a parent server. """ import asyncio -from collections.abc import Sequence import mcp.types as mt import pytest @@ -15,7 +14,7 @@ from docket import Docket 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 @@ class ResourceTracingMiddleware(Middleware): async def on_read_resource( self, context: MiddlewareContext[mt.ReadResourceRequestParams], - call_next: CallNext[mt.ReadResourceRequestParams, Sequence[ResourceContent]], - ) -> Sequence[ResourceContent]: + call_next: CallNext[mt.ReadResourceRequestParams, 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 f3519e0c7..cbceac4b4 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 resource_return_server(): 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 592803d78..4b3eb9779 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 5fbbf9d19..eec59187a 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -642,8 +642,8 @@ class TestResourcesAndTemplates: 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 @@ class TestResourcesAndTemplates: # 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 @@ class TestResourcesAndTemplates: 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 @@ class TestResourcesAndTemplates: # 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 @@ class TestProxyServer: 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 000000000..24cd6328c --- /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.