mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
This reverts commit 12f2422e18.
This commit is contained in:
parent
f9e29cf58e
commit
1efc4bc3ff
19 changed files with 254 additions and 648 deletions
|
|
@ -138,59 +138,8 @@ FastMCP automatically converts your function's return value into the appropriate
|
|||
- **`str`**: Sent as `TextResourceContents` (with `mime_type="text/plain"` by default).
|
||||
- **`dict`, `list`, `pydantic.BaseModel`**: Automatically serialized to a JSON string and sent as `TextResourceContents` (with `mime_type="application/json"` by default).
|
||||
- **`bytes`**: Base64 encoded and sent as `BlobResourceContents`. You should specify an appropriate `mime_type` (e.g., `"image/png"`, `"application/octet-stream"`).
|
||||
- **`ResourceContent`**: Full control over content, MIME type, and metadata. See [ResourceContent](#resourcecontent) below.
|
||||
- **`None`**: Results in an empty resource content list being returned.
|
||||
|
||||
#### ResourceContent
|
||||
|
||||
<VersionBadge version="2.14.1" />
|
||||
|
||||
For complete control over resource responses, return a `ResourceContent` object. This lets you include metadata alongside your resource content, which is useful for cases like Content Security Policy headers for HTML widgets.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.resources import ResourceContent
|
||||
|
||||
mcp = FastMCP(name="WidgetServer")
|
||||
|
||||
@mcp.resource("widget://my-widget")
|
||||
def get_widget() -> ResourceContent:
|
||||
"""Returns an HTML widget with CSP metadata."""
|
||||
return ResourceContent(
|
||||
content="<html><body>My Widget</body></html>",
|
||||
mime_type="text/html",
|
||||
meta={"csp": "script-src 'self'"}
|
||||
)
|
||||
```
|
||||
|
||||
`ResourceContent` accepts three fields:
|
||||
|
||||
**`content`** - The actual resource content. Can be `str` (text content) or `bytes` (binary content). This is the data that will be returned to the client.
|
||||
|
||||
**`mime_type`** - Optional MIME type for the content. Defaults to `"text/plain"` for string content and `"application/octet-stream"` for binary content.
|
||||
|
||||
**`meta`** - Optional metadata dictionary that will be included in the MCP response's `_meta` field. Use this for runtime metadata like Content Security Policy headers, caching hints, or other client-specific data.
|
||||
|
||||
```python
|
||||
# Binary content with metadata
|
||||
@mcp.resource("images://logo")
|
||||
def get_logo() -> ResourceContent:
|
||||
"""Returns a logo image with caching metadata."""
|
||||
with open("logo.png", "rb") as f:
|
||||
image_data = f.read()
|
||||
return ResourceContent(
|
||||
content=image_data,
|
||||
mime_type="image/png",
|
||||
meta={"cache-control": "max-age=3600"}
|
||||
)
|
||||
```
|
||||
|
||||
<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>
|
||||
|
||||
You can still return plain `str` or `bytes` from your resource functions—`ResourceContent` is opt-in for when you need to include metadata.
|
||||
|
||||
### Disabling Resources
|
||||
|
||||
<VersionBadge version="2.8.0" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
from .resource import FunctionResource, Resource, ResourceContent
|
||||
from .resource_manager import ResourceManager
|
||||
from .resource import FunctionResource, Resource
|
||||
from .template import ResourceTemplate
|
||||
from .types import (
|
||||
BinaryResource,
|
||||
|
|
@ -8,6 +7,7 @@ from .types import (
|
|||
HttpResource,
|
||||
TextResource,
|
||||
)
|
||||
from .resource_manager import ResourceManager
|
||||
|
||||
__all__ = [
|
||||
"BinaryResource",
|
||||
|
|
@ -16,7 +16,6 @@ __all__ = [
|
|||
"FunctionResource",
|
||||
"HttpResource",
|
||||
"Resource",
|
||||
"ResourceContent",
|
||||
"ResourceManager",
|
||||
"ResourceTemplate",
|
||||
"TextResource",
|
||||
|
|
|
|||
|
|
@ -2,14 +2,10 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import inspect
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from typing import Annotated, Any
|
||||
from typing import TYPE_CHECKING, Annotated, Any
|
||||
|
||||
import mcp.types
|
||||
import pydantic
|
||||
import pydantic_core
|
||||
from mcp.types import Annotations, Icon
|
||||
from mcp.types import Resource as MCPResource
|
||||
|
|
@ -23,7 +19,6 @@ from pydantic import (
|
|||
)
|
||||
from typing_extensions import Self
|
||||
|
||||
from fastmcp import settings
|
||||
from fastmcp.server.dependencies import get_context, without_injected_parameters
|
||||
from fastmcp.server.tasks.config import TaskConfig
|
||||
from fastmcp.utilities.components import FastMCPComponent
|
||||
|
|
@ -31,103 +26,8 @@ from fastmcp.utilities.types import (
|
|||
get_fn_name,
|
||||
)
|
||||
|
||||
|
||||
class ResourceContent(pydantic.BaseModel):
|
||||
"""Canonical wrapper for resource content.
|
||||
|
||||
This is the internal representation for all resource reads. Users can
|
||||
return ResourceContent directly for full control, or return simpler types
|
||||
(str, bytes, dict) which will be automatically converted.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.resources import ResourceContent
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.resource("widget://my-widget")
|
||||
def my_widget() -> ResourceContent:
|
||||
return ResourceContent(
|
||||
content="<widget html>",
|
||||
meta={"csp": "script-src 'self'"}
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
content: str | bytes
|
||||
mime_type: str | None = None
|
||||
meta: dict[str, Any] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_value(
|
||||
cls,
|
||||
value: Any,
|
||||
mime_type: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> ResourceContent:
|
||||
"""Convert any value to ResourceContent, handling serialization.
|
||||
|
||||
Args:
|
||||
value: The value to convert. Can be:
|
||||
- ResourceContent: returned as-is (meta param ignored)
|
||||
- str: text content
|
||||
- bytes: binary content
|
||||
- other: serialized to JSON string
|
||||
|
||||
mime_type: Optional MIME type override. If not provided:
|
||||
- str → "text/plain"
|
||||
- bytes → "application/octet-stream"
|
||||
- other → "application/json"
|
||||
|
||||
meta: Optional metadata (ignored if value is already ResourceContent)
|
||||
|
||||
Returns:
|
||||
ResourceContent instance
|
||||
"""
|
||||
if isinstance(value, ResourceContent):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return cls(content=value, mime_type=mime_type or "text/plain", meta=meta)
|
||||
if isinstance(value, bytes):
|
||||
return cls(
|
||||
content=value,
|
||||
mime_type=mime_type or "application/octet-stream",
|
||||
meta=meta,
|
||||
)
|
||||
# dict, list, BaseModel, etc → JSON
|
||||
json_str = pydantic_core.to_json(value, fallback=str).decode()
|
||||
return cls(
|
||||
content=json_str, mime_type=mime_type or "application/json", meta=meta
|
||||
)
|
||||
|
||||
def to_mcp_resource_contents(
|
||||
self, uri: AnyUrl | str
|
||||
) -> mcp.types.TextResourceContents | mcp.types.BlobResourceContents:
|
||||
"""Convert to MCP resource contents type.
|
||||
|
||||
Args:
|
||||
uri: The URI of the resource (required by MCP types)
|
||||
|
||||
Returns:
|
||||
TextResourceContents for str content, BlobResourceContents for bytes
|
||||
"""
|
||||
if isinstance(self.content, str):
|
||||
return mcp.types.TextResourceContents(
|
||||
uri=AnyUrl(uri) if isinstance(uri, str) else uri,
|
||||
text=self.content,
|
||||
mimeType=self.mime_type or "text/plain",
|
||||
_meta=self.meta,
|
||||
)
|
||||
else:
|
||||
return mcp.types.BlobResourceContents(
|
||||
uri=AnyUrl(uri) if isinstance(uri, str) else uri,
|
||||
blob=base64.b64encode(self.content).decode(),
|
||||
mimeType=self.mime_type or "application/octet-stream",
|
||||
_meta=self.meta,
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class Resource(FastMCPComponent):
|
||||
|
|
@ -213,41 +113,14 @@ class Resource(FastMCPComponent):
|
|||
raise ValueError("Either name or uri must be provided")
|
||||
return self
|
||||
|
||||
async def read(self) -> str | bytes | ResourceContent:
|
||||
async def read(self) -> str | bytes:
|
||||
"""Read the resource content.
|
||||
|
||||
This method must be implemented by subclasses. For backwards compatibility,
|
||||
subclasses can return str, bytes, or ResourceContent. However, returning
|
||||
str or bytes is deprecated - new code should return ResourceContent.
|
||||
|
||||
Returns:
|
||||
str | bytes | ResourceContent: The resource content. Returning str
|
||||
or bytes is deprecated; prefer ResourceContent for full control
|
||||
over MIME type and metadata.
|
||||
This method is not implemented in the base Resource class and must be
|
||||
implemented by subclasses.
|
||||
"""
|
||||
raise NotImplementedError("Subclasses must implement read()")
|
||||
|
||||
async def _read(self) -> ResourceContent:
|
||||
"""Internal API that always returns ResourceContent.
|
||||
|
||||
This method calls read() and wraps str/bytes results in ResourceContent.
|
||||
ResourceManager and other internal code should call this method instead
|
||||
of read() directly.
|
||||
"""
|
||||
result = await self.read()
|
||||
if isinstance(result, ResourceContent):
|
||||
return result
|
||||
# Deprecated in 2.14.1: returning str/bytes from read()
|
||||
if settings.deprecation_warnings:
|
||||
warnings.warn(
|
||||
f"Resource.read() returning str or bytes is deprecated (since 2.14.1). "
|
||||
f"Return ResourceContent instead. "
|
||||
f"(Resource: {self.__class__.__name__}, URI: {self.uri})",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return ResourceContent.from_value(result, mime_type=self.mime_type)
|
||||
|
||||
def to_mcp_resource(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -351,23 +224,17 @@ class FunctionResource(Resource):
|
|||
task_config=task_config,
|
||||
)
|
||||
|
||||
async def read(self) -> str | bytes | ResourceContent:
|
||||
"""Read the resource by calling the wrapped function.
|
||||
|
||||
Returns:
|
||||
str | bytes | ResourceContent: The resource content. If the user's
|
||||
function returns str, bytes, dict, etc., it will be wrapped
|
||||
in ResourceContent. Nested Resource reads may return raw types.
|
||||
"""
|
||||
async def read(self) -> str | bytes:
|
||||
"""Read the resource by calling the wrapped function."""
|
||||
# self.fn is wrapped by without_injected_parameters which handles
|
||||
# dependency resolution internally
|
||||
result = self.fn()
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
|
||||
# If user returned another Resource, read it recursively
|
||||
if isinstance(result, Resource):
|
||||
return await result.read()
|
||||
|
||||
# Convert any value to ResourceContent
|
||||
return ResourceContent.from_value(result, mime_type=self.mime_type)
|
||||
elif isinstance(result, bytes | str):
|
||||
return result
|
||||
else:
|
||||
return pydantic_core.to_json(result, fallback=str).decode()
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from pydantic import AnyUrl
|
|||
|
||||
from fastmcp import settings
|
||||
from fastmcp.exceptions import NotFoundError, ResourceError
|
||||
from fastmcp.resources.resource import Resource, ResourceContent
|
||||
from fastmcp.resources.resource import Resource
|
||||
from fastmcp.resources.template import (
|
||||
ResourceTemplate,
|
||||
match_uri_template,
|
||||
|
|
@ -286,14 +286,10 @@ class ResourceManager:
|
|||
|
||||
raise NotFoundError(f"Unknown resource: {uri_str}")
|
||||
|
||||
async def read_resource(self, uri: AnyUrl | str) -> ResourceContent:
|
||||
async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
|
||||
"""
|
||||
Internal API for servers: Finds and reads a resource, respecting the
|
||||
filtered protocol path.
|
||||
|
||||
Returns:
|
||||
ResourceContent: The canonical content wrapper. All Resource.read()
|
||||
implementations now return ResourceContent.
|
||||
"""
|
||||
uri_str = str(uri)
|
||||
|
||||
|
|
@ -301,7 +297,7 @@ class ResourceManager:
|
|||
if uri_str in self._resources:
|
||||
resource = await self.get_resource(uri_str)
|
||||
try:
|
||||
return await resource._read()
|
||||
return await resource.read()
|
||||
|
||||
# raise ResourceErrors as-is
|
||||
except ResourceError as e:
|
||||
|
|
@ -325,7 +321,7 @@ class ResourceManager:
|
|||
if (params := match_uri_template(uri_str, key)) is not None:
|
||||
try:
|
||||
resource = await template.create_resource(uri_str, params=params)
|
||||
return await resource._read()
|
||||
return await resource.read()
|
||||
except ResourceError as e:
|
||||
logger.exception(
|
||||
f"Error reading resource from template {uri_str!r}"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from pydantic import Field, ValidationInfo
|
|||
from typing_extensions import override
|
||||
|
||||
from fastmcp.exceptions import ResourceError
|
||||
from fastmcp.resources.resource import Resource, ResourceContent
|
||||
from fastmcp.resources.resource import Resource
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -23,9 +23,9 @@ class TextResource(Resource):
|
|||
|
||||
text: str = Field(description="Text content of the resource")
|
||||
|
||||
async def read(self) -> ResourceContent:
|
||||
async def read(self) -> str:
|
||||
"""Read the text content."""
|
||||
return ResourceContent(content=self.text, mime_type=self.mime_type)
|
||||
return self.text
|
||||
|
||||
|
||||
class BinaryResource(Resource):
|
||||
|
|
@ -33,9 +33,9 @@ class BinaryResource(Resource):
|
|||
|
||||
data: bytes = Field(description="Binary content of the resource")
|
||||
|
||||
async def read(self) -> ResourceContent:
|
||||
async def read(self) -> bytes:
|
||||
"""Read the binary content."""
|
||||
return ResourceContent(content=self.data, mime_type=self.mime_type)
|
||||
return self.data
|
||||
|
||||
|
||||
class FileResource(Resource):
|
||||
|
|
@ -76,14 +76,12 @@ class FileResource(Resource):
|
|||
return not mime_type.startswith("text/")
|
||||
|
||||
@override
|
||||
async def read(self) -> ResourceContent:
|
||||
async def read(self) -> str | bytes:
|
||||
"""Read the file content."""
|
||||
try:
|
||||
if self.is_binary:
|
||||
content: str | bytes = await self._async_path.read_bytes()
|
||||
else:
|
||||
content = await self._async_path.read_text()
|
||||
return ResourceContent(content=content, mime_type=self.mime_type)
|
||||
return await self._async_path.read_bytes()
|
||||
return await self._async_path.read_text()
|
||||
except Exception as e:
|
||||
raise ResourceError(f"Error reading file {self.path}") from e
|
||||
|
||||
|
|
@ -97,12 +95,12 @@ class HttpResource(Resource):
|
|||
)
|
||||
|
||||
@override
|
||||
async def read(self) -> ResourceContent:
|
||||
async def read(self) -> str | bytes:
|
||||
"""Read the HTTP content."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(self.url)
|
||||
_ = response.raise_for_status()
|
||||
return ResourceContent(content=response.text, mime_type=self.mime_type)
|
||||
return response.text
|
||||
|
||||
|
||||
class DirectoryResource(Resource):
|
||||
|
|
@ -147,14 +145,13 @@ class DirectoryResource(Resource):
|
|||
raise ResourceError(f"Error listing directory {self.path}") from e
|
||||
|
||||
@override
|
||||
async def read(self) -> ResourceContent:
|
||||
async def read(self) -> str: # Always returns JSON string
|
||||
"""Read the directory listing."""
|
||||
try:
|
||||
files: list[Path] = await self.list_files()
|
||||
|
||||
file_list = [str(f.relative_to(self.path)) for f in files]
|
||||
|
||||
content = json.dumps({"files": file_list}, indent=2)
|
||||
return ResourceContent(content=content, mime_type=self.mime_type)
|
||||
return json.dumps({"files": file_list}, indent=2)
|
||||
except Exception as e:
|
||||
raise ResourceError(f"Error reading directory {self.path}") from e
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from typing import Any, overload
|
|||
|
||||
import anyio
|
||||
from mcp import LoggingLevel, ServerSession
|
||||
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
||||
from mcp.server.lowlevel.server import request_ctx
|
||||
from mcp.shared.context import RequestContext
|
||||
from mcp.types import (
|
||||
|
|
@ -35,7 +36,6 @@ from pydantic.networks import AnyUrl
|
|||
from starlette.requests import Request
|
||||
from typing_extensions import TypeVar
|
||||
|
||||
from fastmcp.resources.resource import ResourceContent
|
||||
from fastmcp.server.elicitation import (
|
||||
AcceptedElicitation,
|
||||
CancelledElicitation,
|
||||
|
|
@ -275,17 +275,17 @@ class Context:
|
|||
"""
|
||||
return await self.fastmcp._get_prompt_mcp(name, arguments)
|
||||
|
||||
async def read_resource(self, uri: str | AnyUrl) -> list[ResourceContent]:
|
||||
async def read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]:
|
||||
"""Read a resource by URI.
|
||||
|
||||
Args:
|
||||
uri: Resource URI to read
|
||||
|
||||
Returns:
|
||||
List of ResourceContent objects
|
||||
The resource content as either text or bytes
|
||||
"""
|
||||
# Context calls don't have task metadata, so always returns list
|
||||
return await self.fastmcp._read_resource_mcp(uri)
|
||||
return await self.fastmcp._read_resource_mcp(uri) # type: ignore[return-value]
|
||||
|
||||
async def log(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -14,11 +14,12 @@ from key_value.aio.wrappers.statistics import StatisticsWrapper
|
|||
from key_value.aio.wrappers.statistics.wrapper import (
|
||||
KVStoreCollectionStatistics,
|
||||
)
|
||||
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import NotRequired, Self, override
|
||||
|
||||
from fastmcp.prompts.prompt import Prompt
|
||||
from fastmcp.resources.resource import Resource, ResourceContent
|
||||
from fastmcp.resources.resource import Resource
|
||||
from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext
|
||||
from fastmcp.tools.tool import Tool, ToolResult
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
|
@ -35,11 +36,10 @@ GLOBAL_KEY = "__global__"
|
|||
|
||||
|
||||
class CachableReadResourceContents(BaseModel):
|
||||
"""A wrapper for ResourceContent that can be cached."""
|
||||
"""A wrapper for ReadResourceContents that can be cached."""
|
||||
|
||||
content: str | bytes
|
||||
mime_type: str | None = None
|
||||
meta: dict[str, Any] | None = None
|
||||
|
||||
def get_size(self) -> int:
|
||||
return len(self.model_dump_json())
|
||||
|
|
@ -49,18 +49,13 @@ class CachableReadResourceContents(BaseModel):
|
|||
return sum(item.get_size() for item in values)
|
||||
|
||||
@classmethod
|
||||
def wrap(cls, values: Sequence[ResourceContent]) -> list[Self]:
|
||||
return [
|
||||
cls(content=item.content, mime_type=item.mime_type, meta=item.meta)
|
||||
for item in values
|
||||
]
|
||||
def wrap(cls, values: Sequence[ReadResourceContents]) -> list[Self]:
|
||||
return [cls(content=item.content, mime_type=item.mime_type) for item in values]
|
||||
|
||||
@classmethod
|
||||
def unwrap(cls, values: Sequence[Self]) -> list[ResourceContent]:
|
||||
def unwrap(cls, values: Sequence[Self]) -> list[ReadResourceContents]:
|
||||
return [
|
||||
ResourceContent(
|
||||
content=item.content, mime_type=item.mime_type, meta=item.meta
|
||||
)
|
||||
ReadResourceContents(content=item.content, mime_type=item.mime_type)
|
||||
for item in values
|
||||
]
|
||||
|
||||
|
|
@ -390,9 +385,9 @@ class ResponseCachingMiddleware(Middleware):
|
|||
self,
|
||||
context: MiddlewareContext[mcp.types.ReadResourceRequestParams],
|
||||
call_next: CallNext[
|
||||
mcp.types.ReadResourceRequestParams, Sequence[ResourceContent]
|
||||
mcp.types.ReadResourceRequestParams, Sequence[ReadResourceContents]
|
||||
],
|
||||
) -> Sequence[ResourceContent]:
|
||||
) -> Sequence[ReadResourceContents]:
|
||||
"""Read a resource from the cache, if caching is enabled, and the result is in the cache. Otherwise,
|
||||
otherwise call the next middleware and store the result in the cache if caching is enabled."""
|
||||
if self._read_resource_settings.get("enabled") is False:
|
||||
|
|
@ -404,7 +399,7 @@ class ResponseCachingMiddleware(Middleware):
|
|||
if cached_value := await self._read_resource_cache.get(key=cache_key):
|
||||
return CachableReadResourceContents.unwrap(values=cached_value)
|
||||
|
||||
value: Sequence[ResourceContent] = await call_next(context=context)
|
||||
value: Sequence[ReadResourceContents] = await call_next(context=context)
|
||||
cached_value = CachableReadResourceContents.wrap(values=value)
|
||||
|
||||
await self._read_resource_cache.put(
|
||||
|
|
|
|||
|
|
@ -15,10 +15,11 @@ from typing import (
|
|||
)
|
||||
|
||||
import mcp.types as mt
|
||||
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
||||
from typing_extensions import TypeVar
|
||||
|
||||
from fastmcp.prompts.prompt import Prompt
|
||||
from fastmcp.resources.resource import Resource, ResourceContent
|
||||
from fastmcp.resources.resource import Resource
|
||||
from fastmcp.resources.template import ResourceTemplate
|
||||
from fastmcp.tools.tool import Tool, ToolResult
|
||||
|
||||
|
|
@ -163,8 +164,10 @@ class Middleware:
|
|||
async def on_read_resource(
|
||||
self,
|
||||
context: MiddlewareContext[mt.ReadResourceRequestParams],
|
||||
call_next: CallNext[mt.ReadResourceRequestParams, Sequence[ResourceContent]],
|
||||
) -> Sequence[ResourceContent]:
|
||||
call_next: CallNext[
|
||||
mt.ReadResourceRequestParams, Sequence[ReadResourceContents]
|
||||
],
|
||||
) -> Sequence[ReadResourceContents]:
|
||||
return await call_next(context)
|
||||
|
||||
async def on_get_prompt(
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ from logging import Logger
|
|||
from typing import Annotated, Any
|
||||
|
||||
import mcp.types
|
||||
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
||||
from mcp.types import Prompt
|
||||
from pydantic import AnyUrl
|
||||
from typing_extensions import override
|
||||
|
||||
from fastmcp.resources.resource import ResourceContent
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext
|
||||
from fastmcp.tools.tool import Tool, ToolResult
|
||||
|
|
@ -98,7 +98,7 @@ list_resources_tool = Tool.from_function(
|
|||
async def read_resource(
|
||||
context: Context,
|
||||
uri: Annotated[AnyUrl | str, "The URI of the resource to read."],
|
||||
) -> list[ResourceContent]:
|
||||
) -> list[ReadResourceContents]:
|
||||
"""Read a resource available on the server."""
|
||||
return await context.read_resource(uri=uri)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import httpx
|
|||
from mcp.types import ToolAnnotations
|
||||
from pydantic.networks import AnyUrl
|
||||
|
||||
from fastmcp.resources import Resource, ResourceContent, ResourceTemplate
|
||||
from fastmcp.resources import Resource, ResourceTemplate
|
||||
from fastmcp.server.dependencies import get_http_headers
|
||||
from fastmcp.tools.tool import Tool, ToolResult
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
|
@ -187,7 +187,7 @@ class OpenAPIResource(Resource):
|
|||
"""Custom representation to prevent recursion errors when printing."""
|
||||
return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})"
|
||||
|
||||
async def read(self) -> ResourceContent:
|
||||
async def read(self) -> str | bytes:
|
||||
"""Fetch the resource data by making an HTTP request."""
|
||||
try:
|
||||
# Extract path parameters from the URI if present
|
||||
|
|
@ -261,15 +261,11 @@ class OpenAPIResource(Resource):
|
|||
|
||||
if "application/json" in content_type:
|
||||
result = response.json()
|
||||
return ResourceContent(
|
||||
content=json.dumps(result), mime_type="application/json"
|
||||
)
|
||||
return json.dumps(result)
|
||||
elif any(ct in content_type for ct in ["text/", "application/xml"]):
|
||||
return ResourceContent(content=response.text, mime_type=self.mime_type)
|
||||
return response.text
|
||||
else:
|
||||
return ResourceContent(
|
||||
content=response.content, mime_type=self.mime_type
|
||||
)
|
||||
return response.content
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
# Handle HTTP errors (4xx, 5xx)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
|
|
@ -32,7 +31,6 @@ from fastmcp.prompts import Prompt, PromptMessage
|
|||
from fastmcp.prompts.prompt import PromptArgument
|
||||
from fastmcp.prompts.prompt_manager import PromptManager
|
||||
from fastmcp.resources import Resource, ResourceTemplate
|
||||
from fastmcp.resources.resource import ResourceContent
|
||||
from fastmcp.resources.resource_manager import ResourceManager
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.server.dependencies import get_context
|
||||
|
|
@ -186,7 +184,7 @@ class ProxyResourceManager(ResourceManager, ProxyManagerMixin):
|
|||
templates_dict = await self.get_resource_templates()
|
||||
return list(templates_dict.values())
|
||||
|
||||
async def read_resource(self, uri: AnyUrl | str) -> ResourceContent:
|
||||
async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
|
||||
"""Reads a resource, trying local/mounted first, then proxy if not found."""
|
||||
try:
|
||||
# First try local and mounted resources
|
||||
|
|
@ -196,22 +194,10 @@ class ProxyResourceManager(ResourceManager, ProxyManagerMixin):
|
|||
client = await self._get_client()
|
||||
async with client:
|
||||
result = await client.read_resource(uri)
|
||||
if not result:
|
||||
raise ResourceError(
|
||||
f"Remote server returned empty content for {uri}"
|
||||
) from None
|
||||
if isinstance(result[0], TextResourceContents):
|
||||
return ResourceContent(
|
||||
content=result[0].text,
|
||||
mime_type=result[0].mimeType,
|
||||
meta=result[0].meta,
|
||||
)
|
||||
return result[0].text
|
||||
elif isinstance(result[0], BlobResourceContents):
|
||||
return ResourceContent(
|
||||
content=base64.b64decode(result[0].blob),
|
||||
mime_type=result[0].mimeType,
|
||||
meta=result[0].meta,
|
||||
)
|
||||
return result[0].blob
|
||||
else:
|
||||
raise ResourceError(
|
||||
f"Unsupported content type: {type(result[0])}"
|
||||
|
|
@ -346,18 +332,18 @@ class ProxyResource(Resource, MirroredComponent):
|
|||
|
||||
task_config: TaskConfig = TaskConfig(mode="forbidden")
|
||||
_client: Client
|
||||
_cached_content: ResourceContent | None = None
|
||||
_value: str | bytes | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Client,
|
||||
*,
|
||||
_cached_content: ResourceContent | None = None,
|
||||
_value: str | bytes | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self._client = client
|
||||
self._cached_content = _cached_content
|
||||
self._value = _value
|
||||
|
||||
@classmethod
|
||||
def from_mcp_resource(
|
||||
|
|
@ -381,27 +367,17 @@ class ProxyResource(Resource, MirroredComponent):
|
|||
_mirrored=True,
|
||||
)
|
||||
|
||||
async def read(self) -> ResourceContent:
|
||||
async def read(self) -> str | bytes:
|
||||
"""Read the resource content from the remote server."""
|
||||
if self._cached_content is not None:
|
||||
return self._cached_content
|
||||
if self._value is not None:
|
||||
return self._value
|
||||
|
||||
async with self._client:
|
||||
result = await self._client.read_resource(self.uri)
|
||||
if not result:
|
||||
raise ResourceError(f"Remote server returned empty content for {self.uri}")
|
||||
if isinstance(result[0], TextResourceContents):
|
||||
return ResourceContent(
|
||||
content=result[0].text,
|
||||
mime_type=result[0].mimeType,
|
||||
meta=result[0].meta,
|
||||
)
|
||||
return result[0].text
|
||||
elif isinstance(result[0], BlobResourceContents):
|
||||
return ResourceContent(
|
||||
content=base64.b64decode(result[0].blob),
|
||||
mime_type=result[0].mimeType,
|
||||
meta=result[0].meta,
|
||||
)
|
||||
return result[0].blob
|
||||
else:
|
||||
raise ResourceError(f"Unsupported content type: {type(result[0])}")
|
||||
|
||||
|
|
@ -453,22 +429,10 @@ class ProxyTemplate(ResourceTemplate, MirroredComponent):
|
|||
async with self._client:
|
||||
result = await self._client.read_resource(parameterized_uri)
|
||||
|
||||
if not result:
|
||||
raise ResourceError(
|
||||
f"Remote server returned empty content for {parameterized_uri}"
|
||||
)
|
||||
if isinstance(result[0], TextResourceContents):
|
||||
cached_content = ResourceContent(
|
||||
content=result[0].text,
|
||||
mime_type=result[0].mimeType,
|
||||
meta=result[0].meta,
|
||||
)
|
||||
value = result[0].text
|
||||
elif isinstance(result[0], BlobResourceContents):
|
||||
cached_content = ResourceContent(
|
||||
content=base64.b64decode(result[0].blob),
|
||||
mime_type=result[0].mimeType,
|
||||
meta=result[0].meta,
|
||||
)
|
||||
value = result[0].blob
|
||||
else:
|
||||
raise ResourceError(f"Unsupported content type: {type(result[0])}")
|
||||
|
||||
|
|
@ -482,7 +446,7 @@ class ProxyTemplate(ResourceTemplate, MirroredComponent):
|
|||
icons=self.icons,
|
||||
meta=self.meta,
|
||||
tags=(self.meta or {}).get("_fastmcp", {}).get("tags", []),
|
||||
_cached_content=cached_content,
|
||||
_value=value,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import httpx
|
|||
import mcp.types
|
||||
import uvicorn
|
||||
from docket import Docket, Worker
|
||||
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
||||
from mcp.server.lowlevel.server import LifespanResultT, NotificationOptions
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
|
@ -62,7 +63,7 @@ from fastmcp.mcp_config import MCPConfig
|
|||
from fastmcp.prompts import Prompt
|
||||
from fastmcp.prompts.prompt import FunctionPrompt
|
||||
from fastmcp.prompts.prompt_manager import PromptManager
|
||||
from fastmcp.resources.resource import FunctionResource, Resource, ResourceContent
|
||||
from fastmcp.resources.resource import FunctionResource, Resource
|
||||
from fastmcp.resources.resource_manager import ResourceManager
|
||||
from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate
|
||||
from fastmcp.server.auth import AuthProvider
|
||||
|
|
@ -735,7 +736,26 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
# Graceful degradation: if we got here with task_meta, something went wrong
|
||||
# (This should be unreachable now that forbidden raises)
|
||||
if task_meta:
|
||||
mcp_contents = [item.to_mcp_resource_contents(uri) for item in result]
|
||||
mcp_contents = []
|
||||
for item in result:
|
||||
if isinstance(item.content, str):
|
||||
mcp_contents.append(
|
||||
mcp.types.TextResourceContents(
|
||||
uri=uri,
|
||||
text=item.content,
|
||||
mimeType=item.mime_type or "text/plain",
|
||||
)
|
||||
)
|
||||
elif isinstance(item.content, bytes):
|
||||
import base64
|
||||
|
||||
mcp_contents.append(
|
||||
mcp.types.BlobResourceContents(
|
||||
uri=uri,
|
||||
blob=base64.b64encode(item.content).decode(),
|
||||
mimeType=item.mime_type or "application/octet-stream",
|
||||
)
|
||||
)
|
||||
return mcp.types.ServerResult(
|
||||
mcp.types.ReadResourceResult(
|
||||
contents=mcp_contents,
|
||||
|
|
@ -751,7 +771,27 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
if isinstance(result, mcp.types.ServerResult):
|
||||
return result
|
||||
|
||||
mcp_contents = [item.to_mcp_resource_contents(uri) for item in result]
|
||||
mcp_contents = []
|
||||
for item in result:
|
||||
if isinstance(item.content, str):
|
||||
mcp_contents.append(
|
||||
mcp.types.TextResourceContents(
|
||||
uri=uri,
|
||||
text=item.content,
|
||||
mimeType=item.mime_type or "text/plain",
|
||||
)
|
||||
)
|
||||
elif isinstance(item.content, bytes):
|
||||
import base64
|
||||
|
||||
mcp_contents.append(
|
||||
mcp.types.BlobResourceContents(
|
||||
uri=uri,
|
||||
blob=base64.b64encode(item.content).decode(),
|
||||
mimeType=item.mime_type or "application/octet-stream",
|
||||
)
|
||||
)
|
||||
|
||||
return mcp.types.ServerResult(
|
||||
mcp.types.ReadResourceResult(contents=mcp_contents)
|
||||
)
|
||||
|
|
@ -1624,7 +1664,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
|
||||
raise NotFoundError(f"Unknown tool: {tool_name!r}")
|
||||
|
||||
async def _read_resource_mcp(self, uri: AnyUrl | str) -> list[ResourceContent]:
|
||||
async def _read_resource_mcp(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
|
||||
"""
|
||||
Handle MCP 'readResource' requests.
|
||||
|
||||
|
|
@ -1635,7 +1675,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
async with fastmcp.server.context.Context(fastmcp=self):
|
||||
try:
|
||||
# Task routing handled by custom handler
|
||||
return list[ResourceContent](await self._read_resource_middleware(uri))
|
||||
return list[ReadResourceContents](
|
||||
await self._read_resource_middleware(uri)
|
||||
)
|
||||
except DisabledError as e:
|
||||
# convert to NotFoundError to avoid leaking resource presence
|
||||
raise NotFoundError(f"Unknown resource: {str(uri)!r}") from e
|
||||
|
|
@ -1646,7 +1688,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
async def _read_resource_middleware(
|
||||
self,
|
||||
uri: AnyUrl | str,
|
||||
) -> list[ResourceContent]:
|
||||
) -> list[ReadResourceContents]:
|
||||
"""
|
||||
Applies this server's middleware and delegates the filtered call to the manager.
|
||||
"""
|
||||
|
|
@ -1670,7 +1712,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
async def _read_resource(
|
||||
self,
|
||||
context: MiddlewareContext[mcp.types.ReadResourceRequestParams],
|
||||
) -> list[ResourceContent]:
|
||||
) -> list[ReadResourceContents]:
|
||||
"""
|
||||
Read a resource
|
||||
"""
|
||||
|
|
@ -1704,11 +1746,12 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
resource = await self._resource_manager.get_resource(uri_str)
|
||||
if self._should_enable_component(resource):
|
||||
content = await self._resource_manager.read_resource(uri_str)
|
||||
# read_resource() always returns ResourceContent now
|
||||
# Use mime_type from ResourceContent if set, otherwise from resource
|
||||
if content.mime_type is None:
|
||||
content.mime_type = resource.mime_type
|
||||
return [content]
|
||||
return [
|
||||
ReadResourceContents(
|
||||
content=content,
|
||||
mime_type=resource.mime_type,
|
||||
)
|
||||
]
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,12 @@ Converts raw task return values to MCP result types.
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import mcp.types
|
||||
import pydantic_core
|
||||
|
||||
from fastmcp.resources.resource import ResourceContent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
|
|
@ -150,16 +149,13 @@ async def convert_prompt_result(
|
|||
|
||||
|
||||
async def convert_resource_result(
|
||||
server: FastMCP,
|
||||
raw_value: str | bytes | ResourceContent,
|
||||
uri: str,
|
||||
client_task_id: str,
|
||||
server: FastMCP, raw_value: Any, uri: str, client_task_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Convert resource result to MCP resource contents dict.
|
||||
"""Convert raw resource return value to MCP resource contents dict.
|
||||
|
||||
Args:
|
||||
server: FastMCP server instance
|
||||
raw_value: Result from the resource function (str, bytes, or ResourceContent)
|
||||
raw_value: The raw return value from user's resource function (str or bytes)
|
||||
uri: Resource URI (for the contents response)
|
||||
client_task_id: Client task ID for related-task metadata
|
||||
|
||||
|
|
@ -173,32 +169,38 @@ async def convert_resource_result(
|
|||
}
|
||||
}
|
||||
|
||||
# Convert to ResourceContent if needed (handles str, bytes)
|
||||
if not isinstance(raw_value, ResourceContent):
|
||||
raw_value = ResourceContent.from_value(raw_value)
|
||||
|
||||
# Extract content from ResourceContent
|
||||
content = raw_value.content
|
||||
mime_type = raw_value.mime_type
|
||||
content_meta = raw_value.meta
|
||||
|
||||
if isinstance(content, str):
|
||||
content_dict: dict[str, Any] = {
|
||||
"uri": uri,
|
||||
"text": content,
|
||||
"mimeType": mime_type or "text/plain",
|
||||
# Resources return str or bytes directly
|
||||
if isinstance(raw_value, str):
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": uri,
|
||||
"text": raw_value,
|
||||
"mimeType": "text/plain",
|
||||
}
|
||||
],
|
||||
"_meta": related_task_meta,
|
||||
}
|
||||
elif isinstance(raw_value, bytes):
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": uri,
|
||||
"blob": base64.b64encode(raw_value).decode(),
|
||||
"mimeType": "application/octet-stream",
|
||||
}
|
||||
],
|
||||
"_meta": related_task_meta,
|
||||
}
|
||||
else:
|
||||
content_dict = {
|
||||
"uri": uri,
|
||||
"blob": base64.b64encode(content).decode(),
|
||||
"mimeType": mime_type or "application/octet-stream",
|
||||
# Fallback: convert to JSON string
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": uri,
|
||||
"text": json.dumps(raw_value),
|
||||
"mimeType": "application/json",
|
||||
}
|
||||
],
|
||||
"_meta": related_task_meta,
|
||||
}
|
||||
|
||||
if content_meta:
|
||||
content_dict["_meta"] = content_meta
|
||||
|
||||
return {
|
||||
"contents": [content_dict],
|
||||
"_meta": related_task_meta,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from pydantic import FileUrl
|
|||
|
||||
from fastmcp.exceptions import ResourceError
|
||||
from fastmcp.resources import FileResource
|
||||
from fastmcp.resources.resource import ResourceContent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -62,10 +61,9 @@ class TestFileResource:
|
|||
name="test",
|
||||
path=temp_file,
|
||||
)
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert result.content == "test content"
|
||||
assert result.mime_type == "text/plain"
|
||||
content = await resource.read()
|
||||
assert content == "test content"
|
||||
assert resource.mime_type == "text/plain"
|
||||
|
||||
async def test_read_binary_file(self, temp_file: Path):
|
||||
"""Test reading a file as binary."""
|
||||
|
|
@ -75,9 +73,8 @@ class TestFileResource:
|
|||
path=temp_file,
|
||||
is_binary=True,
|
||||
)
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert result.content == b"test content"
|
||||
content = await resource.read()
|
||||
assert content == b"test content"
|
||||
|
||||
def test_relative_path_error(self):
|
||||
"""Test error on relative path."""
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import pytest
|
||||
from pydantic import AnyUrl, BaseModel
|
||||
|
||||
from fastmcp.resources.resource import FunctionResource, ResourceContent
|
||||
from fastmcp.resources.resource import FunctionResource
|
||||
|
||||
|
||||
class TestFunctionResource:
|
||||
|
|
@ -36,10 +36,9 @@ class TestFunctionResource:
|
|||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert result.content == "Hello, world!"
|
||||
assert result.mime_type == "text/plain"
|
||||
content = await resource.read()
|
||||
assert content == "Hello, world!"
|
||||
assert resource.mime_type == "text/plain"
|
||||
|
||||
async def test_read_binary(self):
|
||||
"""Test reading binary data from a FunctionResource."""
|
||||
|
|
@ -52,9 +51,8 @@ class TestFunctionResource:
|
|||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert result.content == b"Hello, world!"
|
||||
content = await resource.read()
|
||||
assert content == b"Hello, world!"
|
||||
|
||||
async def test_json_conversion(self):
|
||||
"""Test automatic JSON conversion of non-string results."""
|
||||
|
|
@ -67,10 +65,9 @@ class TestFunctionResource:
|
|||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert isinstance(result.content, str)
|
||||
assert '"key":"value"' in result.content
|
||||
content = await resource.read()
|
||||
assert isinstance(content, str)
|
||||
assert '"key":"value"' in content
|
||||
|
||||
async def test_error_handling(self):
|
||||
"""Test error handling in FunctionResource."""
|
||||
|
|
@ -97,9 +94,8 @@ class TestFunctionResource:
|
|||
name="test",
|
||||
fn=lambda: MyModel(name="test"),
|
||||
)
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert result.content == '{"name":"test"}'
|
||||
content = await resource.read()
|
||||
assert content == '{"name":"test"}'
|
||||
|
||||
async def test_custom_type_conversion(self):
|
||||
"""Test handling of custom types."""
|
||||
|
|
@ -116,9 +112,8 @@ class TestFunctionResource:
|
|||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert isinstance(result.content, str)
|
||||
content = await resource.read()
|
||||
assert isinstance(content, str)
|
||||
|
||||
async def test_async_read_text(self):
|
||||
"""Test reading text from async FunctionResource."""
|
||||
|
|
@ -131,133 +126,6 @@ class TestFunctionResource:
|
|||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert result.content == "Hello, world!"
|
||||
assert result.mime_type == "text/plain"
|
||||
|
||||
async def test_resource_content_text(self):
|
||||
"""Test returning ResourceContent with text content."""
|
||||
|
||||
def get_data() -> ResourceContent:
|
||||
return ResourceContent(
|
||||
content="Hello, world!",
|
||||
mime_type="text/html",
|
||||
meta={"csp": "script-src 'self'"},
|
||||
)
|
||||
|
||||
resource = FunctionResource(
|
||||
uri=AnyUrl("function://test"),
|
||||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert result.content == "Hello, world!"
|
||||
assert result.mime_type == "text/html"
|
||||
assert result.meta == {"csp": "script-src 'self'"}
|
||||
|
||||
async def test_resource_content_binary(self):
|
||||
"""Test returning ResourceContent with binary content."""
|
||||
|
||||
def get_data() -> ResourceContent:
|
||||
return ResourceContent(
|
||||
content=b"\x00\x01\x02",
|
||||
mime_type="application/octet-stream",
|
||||
)
|
||||
|
||||
resource = FunctionResource(
|
||||
uri=AnyUrl("function://test"),
|
||||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert result.content == b"\x00\x01\x02"
|
||||
assert result.mime_type == "application/octet-stream"
|
||||
assert result.meta is None
|
||||
|
||||
async def test_resource_content_without_meta(self):
|
||||
"""Test returning ResourceContent without meta."""
|
||||
|
||||
def get_data() -> ResourceContent:
|
||||
return ResourceContent(content="plain text")
|
||||
|
||||
resource = FunctionResource(
|
||||
uri=AnyUrl("function://test"),
|
||||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert result.content == "plain text"
|
||||
assert result.mime_type is None
|
||||
assert result.meta is None
|
||||
|
||||
async def test_async_resource_content(self):
|
||||
"""Test async function returning ResourceContent."""
|
||||
|
||||
async def get_data() -> ResourceContent:
|
||||
return ResourceContent(
|
||||
content="async content",
|
||||
meta={"key": "value"},
|
||||
)
|
||||
|
||||
resource = FunctionResource(
|
||||
uri=AnyUrl("function://test"),
|
||||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert result.content == "async content"
|
||||
assert result.meta == {"key": "value"}
|
||||
|
||||
|
||||
class TestResourceContentToMcp:
|
||||
"""Test ResourceContent.to_mcp_resource_contents method."""
|
||||
|
||||
def test_text_content_to_mcp(self):
|
||||
"""Test converting text ResourceContent to MCP type."""
|
||||
rc = ResourceContent(
|
||||
content="hello world",
|
||||
mime_type="text/html",
|
||||
meta={"csp": "script-src 'self'"},
|
||||
)
|
||||
mcp_content = rc.to_mcp_resource_contents("resource://test")
|
||||
|
||||
assert hasattr(mcp_content, "text")
|
||||
assert mcp_content.text == "hello world"
|
||||
assert mcp_content.mimeType == "text/html"
|
||||
assert mcp_content.meta == {"csp": "script-src 'self'"}
|
||||
|
||||
def test_binary_content_to_mcp(self):
|
||||
"""Test converting binary ResourceContent to MCP type."""
|
||||
rc = ResourceContent(
|
||||
content=b"\x00\x01\x02",
|
||||
mime_type="application/octet-stream",
|
||||
meta={"encoding": "raw"},
|
||||
)
|
||||
mcp_content = rc.to_mcp_resource_contents("resource://test")
|
||||
|
||||
assert hasattr(mcp_content, "blob")
|
||||
assert mcp_content.blob == "AAEC" # base64 of \x00\x01\x02
|
||||
assert mcp_content.mimeType == "application/octet-stream"
|
||||
assert mcp_content.meta == {"encoding": "raw"}
|
||||
|
||||
def test_default_mime_types(self):
|
||||
"""Test default mime types are applied correctly."""
|
||||
text_rc = ResourceContent(content="text")
|
||||
text_mcp = text_rc.to_mcp_resource_contents("resource://test")
|
||||
assert text_mcp.mimeType == "text/plain"
|
||||
|
||||
binary_rc = ResourceContent(content=b"binary")
|
||||
binary_mcp = binary_rc.to_mcp_resource_contents("resource://test")
|
||||
assert binary_mcp.mimeType == "application/octet-stream"
|
||||
|
||||
def test_none_meta(self):
|
||||
"""Test that None meta is handled correctly."""
|
||||
rc = ResourceContent(content="no meta")
|
||||
mcp_content = rc.to_mcp_resource_contents("resource://test")
|
||||
|
||||
assert mcp_content.meta is None
|
||||
content = await resource.read()
|
||||
assert content == "Hello, world!"
|
||||
assert resource.mime_type == "text/plain"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from fastmcp.resources import (
|
|||
ResourceManager,
|
||||
ResourceTemplate,
|
||||
)
|
||||
from fastmcp.resources.resource import FunctionResource, ResourceContent
|
||||
from fastmcp.resources.resource import FunctionResource
|
||||
from fastmcp.utilities.tests import caplog_for_fastmcp
|
||||
|
||||
|
||||
|
|
@ -303,8 +303,8 @@ class TestResourceManager:
|
|||
|
||||
resource = await manager.get_resource(AnyUrl("greet://world"))
|
||||
assert isinstance(resource, FunctionResource)
|
||||
result = await resource.read()
|
||||
assert result.content == "Hello, world!"
|
||||
content = await resource.read()
|
||||
assert content == "Hello, world!"
|
||||
|
||||
async def test_get_unknown_resource(self):
|
||||
"""Test getting a non-existent resource."""
|
||||
|
|
@ -559,8 +559,8 @@ class TestCustomResourceKeys:
|
|||
# Using a URI that matches the custom key pattern
|
||||
resource = await manager.get_resource("custom://greet/world")
|
||||
assert isinstance(resource, FunctionResource)
|
||||
result = await resource.read()
|
||||
assert result.content == "Hello, world!"
|
||||
content = await resource.read()
|
||||
assert content == "Hello, world!"
|
||||
|
||||
# Shouldn't work with the original template pattern
|
||||
with pytest.raises(NotFoundError, match="Unknown resource"):
|
||||
|
|
@ -591,14 +591,12 @@ class TestQueryOnlyTemplates:
|
|||
|
||||
# Should work without query param (uses default)
|
||||
resource = await manager.get_resource("data://config")
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert isinstance(result.content, str)
|
||||
assert result.content == "Config in json format"
|
||||
content = await resource.read()
|
||||
assert content == "Config in json format"
|
||||
|
||||
# Should also work via read_resource
|
||||
result = await manager.read_resource("data://config")
|
||||
assert result.content == "Config in json format"
|
||||
content = await manager.read_resource("data://config")
|
||||
assert content == "Config in json format"
|
||||
|
||||
async def test_template_with_only_query_params_with_query_string(self):
|
||||
"""Test that templates with only query params work with query string."""
|
||||
|
|
@ -616,14 +614,12 @@ class TestQueryOnlyTemplates:
|
|||
|
||||
# Should work with query param (overrides default)
|
||||
resource = await manager.get_resource("data://config?format=xml")
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert isinstance(result.content, str)
|
||||
assert result.content == "Config in xml format"
|
||||
content = await resource.read()
|
||||
assert content == "Config in xml format"
|
||||
|
||||
# Should also work via read_resource
|
||||
result = await manager.read_resource("data://config?format=xml")
|
||||
assert result.content == "Config in xml format"
|
||||
content = await manager.read_resource("data://config?format=xml")
|
||||
assert content == "Config in xml format"
|
||||
|
||||
async def test_template_with_only_multiple_query_params(self):
|
||||
"""Test template with only multiple query parameters."""
|
||||
|
|
@ -640,16 +636,16 @@ class TestQueryOnlyTemplates:
|
|||
manager.add_template(template)
|
||||
|
||||
# No query params - use all defaults
|
||||
result = await manager.read_resource("data://items")
|
||||
assert result.content == "Data in json (limit: 10)"
|
||||
content = await manager.read_resource("data://items")
|
||||
assert content == "Data in json (limit: 10)"
|
||||
|
||||
# Partial query params
|
||||
result = await manager.read_resource("data://items?format=xml")
|
||||
assert result.content == "Data in xml (limit: 10)"
|
||||
content = await manager.read_resource("data://items?format=xml")
|
||||
assert content == "Data in xml (limit: 10)"
|
||||
|
||||
# All query params
|
||||
result = await manager.read_resource("data://items?format=xml&limit=20")
|
||||
assert result.content == "Data in xml (limit: 20)"
|
||||
content = await manager.read_resource("data://items?format=xml&limit=20")
|
||||
assert content == "Data in xml (limit: 20)"
|
||||
|
||||
async def test_has_resource_with_query_only_template(self):
|
||||
"""Test that has_resource() works with query-only templates.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from pydantic import BaseModel
|
|||
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.resources import ResourceTemplate
|
||||
from fastmcp.resources.resource import FunctionResource, ResourceContent
|
||||
from fastmcp.resources.resource import FunctionResource
|
||||
from fastmcp.resources.template import match_uri_template
|
||||
|
||||
|
||||
|
|
@ -183,9 +183,9 @@ class TestResourceTemplate:
|
|||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
result = await resource.read()
|
||||
assert isinstance(result.content, str)
|
||||
data = json.loads(result.content)
|
||||
content = await resource.read()
|
||||
assert isinstance(content, str)
|
||||
data = json.loads(content)
|
||||
assert data == {"key": "foo", "value": 123}
|
||||
|
||||
async def test_async_text_resource(self):
|
||||
|
|
@ -206,8 +206,8 @@ class TestResourceTemplate:
|
|||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
result = await resource.read()
|
||||
assert result.content == "Hello, world!"
|
||||
content = await resource.read()
|
||||
assert content == "Hello, world!"
|
||||
|
||||
async def test_async_binary_resource(self):
|
||||
"""Test creating a binary resource from async function."""
|
||||
|
|
@ -227,8 +227,8 @@ class TestResourceTemplate:
|
|||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
result = await resource.read()
|
||||
assert result.content == b"test"
|
||||
content = await resource.read()
|
||||
assert content == b"test"
|
||||
|
||||
async def test_basemodel_conversion(self):
|
||||
"""Test handling of BaseModel types."""
|
||||
|
|
@ -252,9 +252,9 @@ class TestResourceTemplate:
|
|||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
result = await resource.read()
|
||||
assert isinstance(result.content, str)
|
||||
data = json.loads(result.content)
|
||||
content = await resource.read()
|
||||
assert isinstance(content, str)
|
||||
data = json.loads(content)
|
||||
assert data == {"key": "foo", "value": 123}
|
||||
|
||||
async def test_custom_type_conversion(self):
|
||||
|
|
@ -282,8 +282,8 @@ class TestResourceTemplate:
|
|||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
result = await resource.read()
|
||||
assert result.content == '"hello"'
|
||||
content = await resource.read()
|
||||
assert content == '"hello"'
|
||||
|
||||
async def test_wildcard_param_can_create_resource(self):
|
||||
"""Test that wildcard parameters are valid."""
|
||||
|
|
@ -392,8 +392,8 @@ class TestResourceTemplate:
|
|||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
result = await resource.read()
|
||||
assert result.content == "X was foo"
|
||||
content = await resource.read()
|
||||
assert content == "X was foo"
|
||||
|
||||
|
||||
class TestMatchUriTemplate:
|
||||
|
|
@ -678,8 +678,8 @@ class TestContextHandling:
|
|||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
result = await resource.read()
|
||||
assert result.content == "42"
|
||||
content = await resource.read()
|
||||
assert content == "42"
|
||||
|
||||
async def test_context_optional(self):
|
||||
"""Test that context is optional when creating resources."""
|
||||
|
|
@ -704,8 +704,8 @@ class TestContextHandling:
|
|||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
result = await resource.read()
|
||||
assert result.content == "42"
|
||||
content = await resource.read()
|
||||
assert content == "42"
|
||||
|
||||
async def test_context_with_functools_wraps_decorator(self):
|
||||
"""Regression test for #2524: decorated templates with Context should work."""
|
||||
|
|
@ -733,10 +733,8 @@ class TestContextHandling:
|
|||
|
||||
async with context:
|
||||
resource = await template.create_resource("test://42", {"item_id": 42})
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert isinstance(result.content, str)
|
||||
assert result.content == "item: 42"
|
||||
content = await resource.read()
|
||||
assert content == "item: 42"
|
||||
|
||||
|
||||
class TestQueryParameterExtraction:
|
||||
|
|
@ -808,11 +806,10 @@ class TestQueryParameterTypeCoercion:
|
|||
{"resource": "docs", "page": "5"},
|
||||
)
|
||||
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert isinstance(result.content, str)
|
||||
assert '"page":5' in result.content
|
||||
assert '"type":"int"' in result.content
|
||||
content = await resource.read()
|
||||
# TODO(ty): remove when ty supports `in` on str | bytes
|
||||
assert '"page":5' in content # type: ignore[operator]
|
||||
assert '"type":"int"' in content # type: ignore[operator]
|
||||
|
||||
async def test_bool_coercion(self):
|
||||
"""Test boolean type coercion for query parameters."""
|
||||
|
|
@ -831,20 +828,18 @@ class TestQueryParameterTypeCoercion:
|
|||
"config://feature?enabled=true",
|
||||
{"name": "feature", "enabled": "true"},
|
||||
)
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert isinstance(result.content, str)
|
||||
assert '"enabled":true' in result.content
|
||||
content = await resource.read()
|
||||
# TODO(ty): remove when ty supports `in` on str | bytes
|
||||
assert '"enabled":true' in content # type: ignore[operator]
|
||||
|
||||
# Test false value
|
||||
resource = await template.create_resource(
|
||||
"config://feature?enabled=false",
|
||||
{"name": "feature", "enabled": "false"},
|
||||
)
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert isinstance(result.content, str)
|
||||
assert '"enabled":false' in result.content
|
||||
content = await resource.read()
|
||||
# TODO(ty): remove when ty supports `in` on str | bytes
|
||||
assert '"enabled":false' in content # type: ignore[operator]
|
||||
|
||||
async def test_float_coercion(self):
|
||||
"""Test float type coercion for query parameters."""
|
||||
|
|
@ -867,11 +862,10 @@ class TestQueryParameterTypeCoercion:
|
|||
{"service": "api", "threshold": "0.95"},
|
||||
)
|
||||
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert isinstance(result.content, str)
|
||||
assert '"threshold":0.95' in result.content
|
||||
assert '"type":"float"' in result.content
|
||||
content = await resource.read()
|
||||
# TODO(ty): remove when ty supports `in` on str | bytes
|
||||
assert '"threshold":0.95' in content # type: ignore[operator]
|
||||
assert '"type":"float"' in content # type: ignore[operator]
|
||||
|
||||
|
||||
class TestQueryParameterValidation:
|
||||
|
|
@ -929,11 +923,10 @@ class TestQueryParameterWithDefaults:
|
|||
{"id": "123"},
|
||||
)
|
||||
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert isinstance(result.content, str)
|
||||
assert '"format":"json"' in result.content
|
||||
assert '"verbose":false' in result.content
|
||||
content = await resource.read()
|
||||
# TODO(ty): remove when ty supports `in` on str | bytes
|
||||
assert '"format":"json"' in content # type: ignore[operator]
|
||||
assert '"verbose":false' in content # type: ignore[operator]
|
||||
|
||||
async def test_partial_query_params(self):
|
||||
"""Test providing only some query parameters."""
|
||||
|
|
@ -955,12 +948,11 @@ class TestQueryParameterWithDefaults:
|
|||
{"id": "123", "limit": "20"},
|
||||
)
|
||||
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert isinstance(result.content, str)
|
||||
assert '"format":"json"' in result.content # default
|
||||
assert '"limit":20' in result.content # provided
|
||||
assert '"offset":0' in result.content # default
|
||||
content = await resource.read()
|
||||
# TODO(ty): remove when ty supports `in` on str | bytes
|
||||
assert '"format":"json"' in content # type: ignore[operator] # default
|
||||
assert '"limit":20' in content # type: ignore[operator] # provided
|
||||
assert '"offset":0' in content # type: ignore[operator] # default
|
||||
|
||||
|
||||
class TestQueryParameterWithWildcards:
|
||||
|
|
@ -992,9 +984,8 @@ class TestQueryParameterWithWildcards:
|
|||
{"path": "src/test/data.txt", "lines": "50"},
|
||||
)
|
||||
|
||||
result = await resource.read()
|
||||
assert isinstance(result, ResourceContent)
|
||||
assert isinstance(result.content, str)
|
||||
assert '"path":"src/test/data.txt"' in result.content
|
||||
assert '"encoding":"utf-8"' in result.content # default
|
||||
assert '"lines":50' in result.content # provided
|
||||
content = await resource.read()
|
||||
# TODO(ty): remove when ty supports `in` on str | bytes
|
||||
assert '"path":"src/test/data.txt"' in content # type: ignore[operator]
|
||||
assert '"encoding":"utf-8"' in content # type: ignore[operator] # default
|
||||
assert '"lines":50' in content # type: ignore[operator] # provided
|
||||
|
|
|
|||
|
|
@ -489,14 +489,10 @@ class TestResourceToolMiddleware:
|
|||
[
|
||||
TextContent(
|
||||
type="text",
|
||||
text='[{"content":"debug=true","mime_type":"text/plain","meta":null}]',
|
||||
text='[{"content":"debug=true","mime_type":"text/plain"}]',
|
||||
)
|
||||
]
|
||||
)
|
||||
assert result.structured_content == snapshot(
|
||||
{
|
||||
"result": [
|
||||
{"content": "debug=true", "mime_type": "text/plain", "meta": None}
|
||||
]
|
||||
}
|
||||
{"result": [{"content": "debug=true", "mime_type": "text/plain"}]}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from pydantic import Field
|
|||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.exceptions import NotFoundError
|
||||
from fastmcp.prompts.prompt import FunctionPrompt, Prompt
|
||||
from fastmcp.resources import Resource, ResourceContent, ResourceTemplate
|
||||
from fastmcp.resources import Resource, ResourceTemplate
|
||||
from fastmcp.server.server import (
|
||||
add_resource_prefix,
|
||||
has_resource_prefix,
|
||||
|
|
@ -616,59 +616,6 @@ class TestResourceDecorator:
|
|||
|
||||
assert resource.meta == meta_data
|
||||
|
||||
async def test_resource_content_with_meta_in_response(self):
|
||||
"""Test that ResourceContent meta is passed through to MCP response."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.resource("resource://widget")
|
||||
def get_widget() -> ResourceContent:
|
||||
return ResourceContent(
|
||||
content="<widget>content</widget>",
|
||||
mime_type="text/html",
|
||||
meta={"csp": "script-src 'self'", "version": "1.0"},
|
||||
)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://widget")
|
||||
assert len(result) == 1
|
||||
assert result[0].text == "<widget>content</widget>" # type: ignore[attr-defined]
|
||||
assert result[0].mimeType == "text/html" # type: ignore[attr-defined]
|
||||
# Meta should be in the response
|
||||
assert result[0].meta == {"csp": "script-src 'self'", "version": "1.0"} # type: ignore[attr-defined]
|
||||
|
||||
async def test_resource_content_binary_with_meta(self):
|
||||
"""Test that ResourceContent with binary content and meta works."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.resource("resource://binary")
|
||||
def get_binary() -> ResourceContent:
|
||||
return ResourceContent(
|
||||
content=b"\x00\x01\x02",
|
||||
meta={"encoding": "raw"},
|
||||
)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://binary")
|
||||
assert len(result) == 1
|
||||
# Binary content comes back as blob
|
||||
assert hasattr(result[0], "blob")
|
||||
assert result[0].meta == {"encoding": "raw"} # type: ignore[attr-defined]
|
||||
|
||||
async def test_resource_content_without_meta(self):
|
||||
"""Test that ResourceContent without meta works (meta is None)."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.resource("resource://plain")
|
||||
def get_plain() -> ResourceContent:
|
||||
return ResourceContent(content="plain content")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("resource://plain")
|
||||
assert len(result) == 1
|
||||
assert result[0].text == "plain content" # type: ignore[attr-defined]
|
||||
# Meta should be None
|
||||
assert result[0].meta is None # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class TestTemplateDecorator:
|
||||
async def test_template_decorator(self):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue