mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 05:24:18 +02:00
Introduce ResourceResult as canonical resource return type (#2734)
This commit is contained in:
parent
04cf7bc458
commit
648684d2bb
36 changed files with 1190 additions and 555 deletions
|
|
@ -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})
|
||||
```
|
||||
|
||||
<Card icon="code" title="@resource Decorator Arguments">
|
||||
|
|
@ -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
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
<VersionBadge version="2.14.1" />
|
||||
#### 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.
|
||||
<VersionBadge version="3.0.0" />
|
||||
|
||||
`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="<html><body>My Widget</body></html>",
|
||||
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
|
||||
```
|
||||
|
||||
<Note>
|
||||
The `meta` field in `ResourceContent` is for runtime metadata specific to this read response. This is separate from the `meta` parameter in `@mcp.resource(meta={...})`, which provides static metadata about the resource definition itself (returned when listing resources).
|
||||
</Note>
|
||||
<Card title="ResourceResult">
|
||||
<ParamField body="contents" type="str | bytes | list[ResourceContent]" required>
|
||||
Content to return. Strings and bytes are wrapped in a single `ResourceContent`. Use a list of `ResourceContent` for multiple items or custom MIME types.
|
||||
</ParamField>
|
||||
<ParamField body="meta" type="dict[str, Any] | None">
|
||||
Result-level metadata, included in the MCP response's `_meta` field.
|
||||
</ParamField>
|
||||
</Card>
|
||||
|
||||
You can still return plain `str` or `bytes` from your resource functions—`ResourceContent` is opt-in for when you need to include metadata.
|
||||
<Card title="ResourceContent">
|
||||
<ParamField body="content" type="Any" required>
|
||||
The content data. Strings and bytes pass through directly. Other types (dict, list, BaseModel) are automatically JSON-serialized.
|
||||
</ParamField>
|
||||
<ParamField body="mime_type" type="str | None">
|
||||
MIME type. Defaults to `text/plain` for strings, `application/octet-stream` for bytes, `application/json` for serialized objects.
|
||||
</ParamField>
|
||||
<ParamField body="meta" type="dict[str, Any] | None">
|
||||
Item-level metadata for this specific content.
|
||||
</ParamField>
|
||||
</Card>
|
||||
|
||||
### 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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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="<widget html>",
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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])}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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="<widget>content</widget>",
|
||||
mime_type="text/html",
|
||||
meta={"csp": "script-src 'self'", "version": "1.0"},
|
||||
def get_widget() -> ResourceResult:
|
||||
return ResourceResult(
|
||||
[
|
||||
ResourceContent(
|
||||
content="<widget>content</widget>",
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
196
v3-notes/resource-internal-types.md
Normal file
196
v3-notes/resource-internal-types.md
Normal file
|
|
@ -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": "<div>content</div>"}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
from fastmcp.resources import ResourceContent, ResourceResult
|
||||
|
||||
def get_html() -> ResourceResult:
|
||||
return ResourceResult([
|
||||
ResourceContent(
|
||||
content="<div>content</div>",
|
||||
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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue